From 135b342ad40eeaa68c192be198fd66251dd2c521 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 10 Feb 2023 09:08:23 -0600 Subject: [PATCH 01/50] Create static uniqueness rules file --- specifyweb/businessrules/uniqueness_rules.py | 76 ++----- .../components/DataModel/uniquness_rules.json | 190 ++++++++++++++++++ 2 files changed, 203 insertions(+), 63 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json diff --git a/specifyweb/businessrules/uniqueness_rules.py b/specifyweb/businessrules/uniqueness_rules.py index 7b37d29ffcb..d386074a274 100644 --- a/specifyweb/businessrules/uniqueness_rules.py +++ b/specifyweb/businessrules/uniqueness_rules.py @@ -1,4 +1,6 @@ +import json from django.core.exceptions import ObjectDoesNotExist +from typing import Dict from specifyweb.specify import models from .orm_signal_handler import orm_signal_handler from .exceptions import BusinessRuleException @@ -27,6 +29,7 @@ def check_unique(instance): else: @orm_signal_handler('pre_save', model_name) def check_unique(instance): + if isinstance(parent_field, dict): return try: parent = getattr(instance, parent_field + '_id', None) except ObjectDoesNotExist: @@ -52,71 +55,18 @@ def check_unique(instance): "conflicting" : list(conflicts.values_list('id', flat=True)[:100])}) return check_unique -UNIQUENESS_RULES = { - 'Accession': { - 'accessionnumber': ['division'], - }, - 'Appraisal': { - 'appraisalnumber': ['accession'], - }, - 'Author': { - 'agent': ['referencework'], - 'ordernumber': ['referencework'], - }, - 'Collection': { - 'collectionname': ['discipline'], - 'code': ['discipline'], - }, - 'Collectionobject': { - 'catalognumber': ['collection'], - }, - 'Collector': { - 'agent': ['collectingevent'], - }, - 'Discipline': { - 'name': ['division'], - }, - 'Division': { - 'name': ['institution'], - }, - 'Gift': { - 'giftnumber': ['discipline'], - }, - 'Groupperson': { - 'member': ['group'], - }, - 'Institution': { - 'name': [None], - }, - 'Loan': { - 'loannumber': ['discipline'], - }, - 'Permit': { - 'permitnumber': [None], - }, - 'Picklist': { - 'name': ['collection'], - }, - 'Preptype': { - 'name': ['collection'], - }, - 'Repositoryagreement': { - 'repositoryagreementnumber': ['division'], - }, - 'Spappresourcedata': { - 'spappresource': [None], - }, - } +RAW_UNIQUENESS_RULES: Dict[str, Dict[str, list]] = json.load(open('specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json')) +def parse_uniqueness_rules(): + PARSED_UNIQUENESS_RULES = {} + for table, value in RAW_UNIQUENESS_RULES.items(): + table = table.lower().capitalize() + if hasattr(models, table): + PARSED_UNIQUENESS_RULES[table] = value -# This check is provided to support the Specify 6.8.01 -# datamodel (schema version 2.9). When support for that -# version is dropped it can be removed and this definition -# can be included in the block above. -if hasattr(models, 'Determiner'): - UNIQUENESS_RULES['Determiner'] = { - 'agent': ['determination'], - } + return PARSED_UNIQUENESS_RULES + +UNIQUENESS_RULES = parse_uniqueness_rules() uniqueness_rules = [make_uniqueness_rule(model, parent_field, unique_field) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json new file mode 100644 index 00000000000..0a4816fb2dd --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -0,0 +1,190 @@ +{ + "Accession":{ + "accessionnumber":[ + "division" + ] + }, + "AccessionAgent":{ + "role":[ + { + "field":"accession", + "otherfields":[ + "agent" + ] + }, + { + "field":"repositoryagreement", + "otherfields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"accession", + "otherfields":[ + "role" + ] + }, + { + "field":"repositoryagreement", + "otherfields":[ + "role" + ] + } + ] + }, + "Appraisal":{ + "appraisalnumber":[ + "accession" + ] + }, + "Author":{ + "agent":[ + "referencework" + ], + "ordernumber":[ + "referencework" + ] + }, + "BorrowAgent":{ + "role":[ + { + "field":"borrow", + "otherfields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"borrow", + "otherfields":[ + "role" + ] + } + ] + }, + "Collection":{ + "collectionname":[ + "discipline" + ], + "code":[ + "discipline" + ] + }, + "CollectionObject":{ + "catalognumber":[ + "collection" + ], + "guid":[ + "institution" + ] + }, + "Collector":{ + "agent":[ + "collectingevent" + ] + }, + "Determiner":{ + "agent":[ + "determination" + ] + }, + "Discipline":{ + "name":[ + "division" + ] + }, + "Division":{ + "name":[ + "institution" + ] + }, + "Gift":{ + "giftnumber":[ + "discipline" + ] + }, + "GiftAgent":{ + "role": [ + { + "field":"gift", + "otherfields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"gift", + "otherfields":[ + "role" + ] + } + ] + }, + "GroupPerson":{ + "member":[ + "group" + ] + }, + "Institution":{ + "name":[ + null + ] + }, + "Loan":{ + "loannumber":[ + "discipline" + ] + }, + "LoanAgent":{ + "role":[ + { + "field":"loan", + "otherfields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"loan", + "otherfields":[ + "role" + ] + } + ] + }, + "Permit":{ + "permitnumber":[ + null + ] + }, + "PickList":{ + "name":[ + "collection" + ] + }, + "Preparation":{ + "barCode":[ + "collection" + ] + }, + "PrepType":{ + "name":[ + "collection" + ] + }, + "RepositoryAgreement":{ + "repositoryagreementnumber":[ + "division" + ] + }, + "SpAppResourceData":{ + "spappresource":[ + null + ] + } + } \ No newline at end of file From 3bc8718ad8b77bcd20e3295ef46a9c6e2a9bcb78 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 10 Feb 2023 09:12:33 -0600 Subject: [PATCH 02/50] Refactor non-interaction business rules on the frontend --- .../components/DataModel/businessRuleDefs.js | 289 ------------ .../components/DataModel/businessRuleDefs.ts | 295 ++++++++++++ .../lib/components/DataModel/businessRules.js | 317 ------------- .../lib/components/DataModel/businessRules.ts | 430 ++++++++++++++++++ .../lib/components/DataModel/legacyTypes.ts | 22 +- .../lib/components/DataModel/saveBlockers.ts | 2 +- .../lib/components/DataModel/specifyModel.ts | 4 +- .../components/DataModel/treeBusinessRules.ts | 11 +- .../js_src/lib/components/Forms/Save.tsx | 66 +-- 9 files changed, 778 insertions(+), 658 deletions(-) delete mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts delete mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js deleted file mode 100644 index 0362abfb406..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js +++ /dev/null @@ -1,289 +0,0 @@ -import {schema} from './schema'; -import {interactionBusinessRules} from './interactionBusinessRules'; - -export const businessRuleDefs = Object.freeze({ - Accession: { - uniqueIn: { - accessionnumber: 'division' - } - }, - AccessionAgent: { - uniqueIn: { - role: [{field: 'accession', otherfields: ['agent']}, {field: 'repositoryagreement', otherfields: ['agent']}], - agent: [{field: 'accession', otherfields: ['role']}, {field: 'repositoryagreement', otherfields: ['role']}] - } - }, - Appraisal: { - uniqueIn: { - appraisalnumber: 'accession' - } - }, - Author: { - uniqueIn: { - agent: 'referencework' - } - }, - BorrowAgent: { - uniqueIn: { - role: {field: 'borrow', otherfields: ['agent']}, - agent: {field: 'borrow', otherfields: ['role']} - } - }, - BorrowMaterial: { - customChecks: { - quantityreturned: function(borrowmaterial) { - var returned = borrowmaterial.get('quantityreturned'); - var newval; - if (returned > borrowmaterial.get('quantity')) { - /*return { - valid: false, - reason: 'value must be < ' + borrowmaterial.get('quantity') - };*/ - newval = borrowmaterial.get('quantity'); - } - if (returned > borrowmaterial.get('quantityresolved')) { - /*return { - valid: false, - reason: 'quantity returned must be less than or equal to quantity resolved' - };*/ - newval = borrowmaterial.get('quantityresolved'); - } - newval && borrowmaterial.set('quantityreturned', newval); - }, - quantityresolved: function(borrowmaterial) { - var resolved = borrowmaterial.get('quantityresolved'); - var newval; - if (resolved > borrowmaterial.get('quantity')) { - /*return { - valid: false, - reason: 'value must be < ' + borrowmaterial.get('quantity') - };*/ - newval = borrowmaterial.get('quantity'); - } - if (resolved < borrowmaterial.get('quantityreturned')) { - /*return { - valid: false, - reason: 'quantity resolved must be greater than or equal to quantity returned' - };*/ - newval = borrowmaterial.get('quantityreturned'); - } - newval && borrowmaterial.set('quantityresolved', newval); - } - } - }, - Collection: { - uniqueIn: { - name: 'discipline' - } - }, - CollectingEvent: { - }, - CollectionObject: { - uniqueIn: { - catalognumber: 'collection', - guid: 'institution', - }, - customInit: function(collectionObject) { - var ceField = collectionObject.specifyModel.getField('collectingevent'); - if (ceField.isDependent() && collectionObject.get('collectingevent') == null) { - collectionObject.set('collectingevent', new schema.models.CollectingEvent.Resource()); - } - } - }, - Collector: { - uniqueIn: { - agent: 'collectingevent' - } - }, - Determination: { - onRemoved: function(det, detCollection) { - // Example usage: - // if (detCollection.related.specifyModel.name == 'CollectionObject') { - // var collectionobject = detCollection.related; - // console.log("removed determination", det, "from collection object", collectionobject); - // } - }, - onAdded: function(det, detCollection) { - // Example usage: - // if (detCollection.related.specifyModel.name == 'CollectionObject') { - // var collectionobject = detCollection.related; - // console.log("added determination", det, "to collection object", collectionobject); - // } - }, - customInit: function(determination) { - if (determination.isNew()) { - const setCurrent = function() { - determination.set('iscurrent', true); - if (determination.collection != null) { - determination.collection.each(function(other) { - if (other.cid !== determination.cid) { - other.set('iscurrent', false); - } - }); - } - }; - if (determination.collection != null) setCurrent(); - determination.on('add', setCurrent); - } - }, - customChecks: { - taxon: determination => determination.rget('taxon', true).then( - taxon => taxon == null ? - { valid: true, action() { determination.set('preferredtaxon', null); }} - : (function recur(taxon) { - return taxon.get('acceptedtaxon') != null ? - taxon.rget('acceptedtaxon', true).then(recur) - : { valid: true, action() { determination.set('preferredtaxon', taxon); }}; - })(taxon)), - - iscurrent: function(determination) { - if (determination.get('iscurrent') && (determination.collection != null)) { - determination.collection.each(function(other) { - if (other.cid !== determination.cid) { - other.set('iscurrent', false); - } - }); - } - if (determination.collection != null && !determination.collection.any(c => c.get('iscurrent'))) { - determination.set('iscurrent', true); - } - return {valid: true}; - } - } - }, - Discipline: { - uniqueIn: { - name: 'division' - } - }, - Division: { - uniqueIn: { - name: 'institution' - } - }, - Gift: { - uniqueIn: { - giftnumber: 'discipline' - } - }, - GiftAgent: { - uniqueIn: { - role: {field: 'gift', otherfields: ['agent']}, - agent: {field: 'gift', otherfields: ['role']} - } - }, - GiftPreparation: { - customChecks: { - quantity: function(iprep) { - interactionBusinessRules.checkPrepAvailability(iprep); - } - } - }, - Institution: { - unique: ['name'] - }, - Loan: { - uniqueIn: { - loannumber: 'discipline' - } - }, - LoanAgent: { - uniqueIn: { - role: {field: 'loan', otherfields: ['agent']}, - agent: {field: 'loan', otherfields: ['role']} - } - }, - /* might be able to use something like this to check when form is loaded after add-items or create-new for invalid amounts due to - changes in other sessions */ - LoanPreparation: { - customChecks: { - quantity: function(iprep) { - interactionBusinessRules.checkPrepAvailability(iprep); - } - } - - }, - LoanReturnPreparation: { - onRemoved: function(loanreturnprep, collection) { - interactionBusinessRules.updateLoanPrep(loanreturnprep, collection); - }, - customInit: function(loanreturnprep) { - interactionBusinessRules.totalLoaned = undefined; - interactionBusinessRules.totalResolved = undefined; - interactionBusinessRules.returned = undefined; - interactionBusinessRules.resolved = undefined; - loanreturnprep.get('quantityreturned') == null && loanreturnprep.set('quantityreturned', 0); - loanreturnprep.get('quantityresolved') == null && loanreturnprep.set('quantityresolved', 0); - }, - customChecks: { - quantityreturned: function(loanreturnprep) { - var returned = loanreturnprep.get('quantityreturned'); - var previousReturned = interactionBusinessRules.previousReturned[loanreturnprep.cid] - ? interactionBusinessRules.previousReturned[loanreturnprep.cid] - : 0; - if (returned != previousReturned) { - var delta = returned - previousReturned; - var resolved = loanreturnprep.get('quantityresolved'); - var totalLoaned = interactionBusinessRules.getTotalLoaned(loanreturnprep); - var totalResolved = interactionBusinessRules.getTotalResolved(loanreturnprep); - var max = totalLoaned - totalResolved; - if (delta + resolved > max) { - loanreturnprep.set('quantityreturned', previousReturned); - } else { - resolved = loanreturnprep.get('quantityresolved') + delta; - interactionBusinessRules.previousResolved[loanreturnprep.cid] = resolved; - loanreturnprep.set('quantityresolved', resolved); - } - interactionBusinessRules.previousReturned[loanreturnprep.cid] = loanreturnprep.get('quantityreturned'); - interactionBusinessRules.updateLoanPrep(loanreturnprep, loanreturnprep.collection); - } - }, - quantityresolved: function(loanreturnprep) { - var resolved = loanreturnprep.get('quantityresolved'); - var previousResolved = interactionBusinessRules.previousResolved[loanreturnprep.cid] - ? interactionBusinessRules.previousResolved[loanreturnprep.cid] - : 0; - if (resolved != previousResolved) { - var returned = loanreturnprep.get('quantityreturned'); - var totalLoaned = interactionBusinessRules.getTotalLoaned(loanreturnprep); - var totalResolved = interactionBusinessRules.getTotalResolved(loanreturnprep); - var max = totalLoaned - totalResolved; - if (resolved > max) { - loanreturnprep.set('quantityresolved', previousResolved); - } - if (resolved < returned) { - interactionBusinessRules.previousReturned[loanreturnprep.cid] = resolved; - loanreturnprep.set('quantityreturned', resolved); - } - interactionBusinessRules.previousResolved[loanreturnprep.cid] = loanreturnprep.get('quantityresolved'); - interactionBusinessRules.updateLoanPrep(loanreturnprep, loanreturnprep.collection); - } - } - } - }, - Permit: { - unique: ['permitnumber'] - }, - PickList: { - uniqueIn: { - name: 'collection' - } - }, - PrepType: { - uniqueIn: { - name: 'collection' - } - }, - SpecifyUser: { - uniqueIn: { - name: 'institution', - }, - }, - RepositoryAgreement: { - uniqueIn: { - repositoryagreementnumber: 'division', - role: {field: 'borrow', otherfields: ['agent']}, - agent: {field: 'borrow', otherfields: ['role']} - } - } - }); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts new file mode 100644 index 00000000000..ed79d6e4471 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -0,0 +1,295 @@ +import { f } from '../../utils/functools'; +import { overwriteReadOnly } from '../../utils/types'; +import { AnySchema, TableFields } from './helperTypes'; +import { interactionBusinessRules } from './interactionBusinessRules'; +import { SpecifyResource } from './legacyTypes'; +import { BusinessRuleResult } from './businessRules'; +import { schema } from './schema'; +import { Collection } from './specifyModel'; +import { + BorrowMaterial, + CollectionObject, + Determination, + GiftPreparation, + LoanPreparation, + LoanReturnPreparation, + Tables, + Taxon, +} from './types'; +import * as uniquenessRules from './uniquness_rules.json'; + +export type BusinessRuleDefs = { + readonly onRemoved?: ( + resource: SpecifyResource, + collection: Collection + ) => void; + readonly uniqueIn?: { [key: string]: string }; + readonly customInit?: (resource: SpecifyResource) => void; + readonly fieldChecks?: { + [FIELDNAME in TableFields as Lowercase]?: ( + resource: SpecifyResource + ) => Promise | void; + }; +}; + +type MappedBusinessRuleDefs = { + [TABLE in keyof Tables]?: BusinessRuleDefs; +}; + +function assignUniquenessRules( + mappedRules: MappedBusinessRuleDefs +): MappedBusinessRuleDefs { + Object.keys(uniquenessRules).forEach((table) => { + if (mappedRules[table] == undefined) + overwriteReadOnly(mappedRules, table, {}); + + overwriteReadOnly(mappedRules[table]!, 'uniqueIn', uniquenessRules[table]); + }); + return mappedRules; +} + +export const businessRuleDefs = f.store( + (): MappedBusinessRuleDefs => + assignUniquenessRules({ + BorrowMaterial: { + fieldChecks: { + quantityreturned: ( + borrowMaterial: SpecifyResource + ): void => { + const returned = borrowMaterial.get('quantityReturned'); + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + var newVal: number | undefined = undefined; + if (quantity && returned && returned > quantity) { + newVal = quantity; + } + if (returned && resolved && returned > resolved) { + newVal = resolved; + } + + newVal && borrowMaterial.set('quantityReturned', newVal); + }, + quantityresolved: ( + borrowMaterial: SpecifyResource + ): void => { + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + const returned = borrowMaterial.get('quantityReturned'); + var newVal: number | undefined = undefined; + if (resolved && quantity && resolved > quantity) { + newVal = quantity; + } + if (resolved && returned && resolved < returned) { + newVal = returned; + } + + newVal && borrowMaterial.set('quantityResolved', newVal); + }, + }, + }, + + CollectionObject: { + customInit: function ( + collectionObject: SpecifyResource + ): void { + var ceField = + collectionObject.specifyModel.getField('collectingEvent'); + if ( + ceField?.isDependent() && + collectionObject.get('collectingEvent') == undefined + ) { + collectionObject.set( + 'collectingEvent', + new schema.models.CollectingEvent.Resource() + ); + } + }, + }, + + Determination: { + customInit: (determinaton: SpecifyResource): void => { + if (determinaton.isNew()) { + const setCurrent = () => { + determinaton.set('isCurrent', true); + if (determinaton.collection != null) { + determinaton.collection.each( + (other: SpecifyResource) => { + if (other.cid !== determinaton.cid) { + other.set('isCurrent', false); + } + } + ); + } + }; + if (determinaton.collection !== null) setCurrent(); + determinaton.on('add', setCurrent); + } + }, + fieldChecks: { + taxon: ( + determination: SpecifyResource + ): Promise | void | undefined => { + return determination + .rget('taxon', true) + .then((taxon: SpecifyResource | null) => + taxon == null + ? { + valid: true, + action: () => { + determination.set('preferredTaxon', null); + }, + } + : (function recur( + taxon: SpecifyResource + ): BusinessRuleResult { + return taxon.get('acceptedTaxon') == null + ? { + valid: true, + action: () => { + determination.set('preferredTaxon', taxon); + }, + } + : taxon.rget('acceptedtaxon', true).then(recur); + })(taxon) + ); + }, + iscurrent: ( + determination: SpecifyResource + ): Promise | void => { + if ( + determination.get('isCurrent') && + determination.collection != null + ) { + determination.collection.each( + (other: SpecifyResource) => { + if (other.cid !== determination.cid) { + other.set('isCurrent', false); + } + } + ); + } + if ( + determination.collection != null && + !determination.collection.any( + (c: SpecifyResource) => c.get('isCurrent') + ) + ) { + determination.set('isCurrent', true); + } + return Promise.resolve({ valid: true }); + }, + }, + }, + GiftPreparation: { + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + interactionBusinessRules.checkPrepAvailability(iprep); + }, + }, + }, + LoanPreparation: { + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + interactionBusinessRules.checkPrepAvailability(iprep); + }, + }, + }, + LoanReturnPreparation: { + onRemoved: ( + loanReturnPrep: SpecifyResource, + collection: Collection + ): void => { + interactionBusinessRules.updateLoanPrep(loanReturnPrep, collection); + }, + customInit: ( + resource: SpecifyResource + ): void => { + interactionBusinessRules.totalLoaned = undefined; + interactionBusinessRules.totalResolved = undefined; + interactionBusinessRules.returned = undefined; + interactionBusinessRules.resolved = undefined; + resource.get('quantityReturned') == null && + resource.set('quantityReturned', 0); + resource.get('quantityResolved') == null && + resource.set('quantityResolved', 0); + }, + fieldChecks: { + quantityreturned: ( + loanReturnPrep: SpecifyResource + ): Promise | void => { + var returned = loanReturnPrep.get('quantityReturned'); + var previousReturned = interactionBusinessRules.previousReturned[ + Number(loanReturnPrep.cid) + ] + ? interactionBusinessRules.previousReturned[ + Number(loanReturnPrep.cid) + ] + : 0; + if (returned !== null && returned != previousReturned) { + var delta = returned - previousReturned; + var resolved = loanReturnPrep.get('quantityResolved'); + var totalLoaned = + interactionBusinessRules.getTotalLoaned(loanReturnPrep); + var totalResolved = + interactionBusinessRules.getTotalResolved(loanReturnPrep); + var max = totalLoaned - totalResolved; + if (resolved !== null && delta + resolved > max) { + loanReturnPrep.set('quantityReturned', previousReturned); + } else { + resolved = loanReturnPrep.get('quantityResolved')! + delta; + interactionBusinessRules.previousResolved[ + Number(loanReturnPrep.cid) + ] = resolved; + loanReturnPrep.set('quantityResolved', resolved); + } + interactionBusinessRules.previousReturned[ + Number(loanReturnPrep.cid) + ] = loanReturnPrep.get('quantityReturned'); + interactionBusinessRules.updateLoanPrep( + loanReturnPrep, + loanReturnPrep.collection + ); + } + }, + quantityresolved: ( + loanReturnPrep: SpecifyResource + ): Promise | void => { + var resolved = loanReturnPrep.get('quantityResolved'); + var previousResolved = interactionBusinessRules.previousResolved[ + Number(loanReturnPrep.cid) + ] + ? interactionBusinessRules.previousResolved[ + Number(loanReturnPrep.cid) + ] + : 0; + if (resolved != previousResolved) { + var returned = loanReturnPrep.get('quantityReturned'); + var totalLoaned = + interactionBusinessRules.getTotalLoaned(loanReturnPrep); + var totalResolved = + interactionBusinessRules.getTotalResolved(loanReturnPrep); + var max = totalLoaned - totalResolved; + if (resolved !== null && returned !== null) { + if (resolved > max) { + loanReturnPrep.set('quantityResolved', previousResolved); + } + if (resolved < returned) { + interactionBusinessRules.previousReturned[ + Number(loanReturnPrep.cid) + ] = resolved; + loanReturnPrep.set('quantityReturned', resolved); + } + } + interactionBusinessRules.previousResolved[ + Number(loanReturnPrep.cid) + ] = loanReturnPrep.get('quantityResolved'); + interactionBusinessRules.updateLoanPrep( + loanReturnPrep, + loanReturnPrep.collection + ); + } + }, + }, + }, + } as const) +); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js deleted file mode 100644 index 9d3edb0ea50..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js +++ /dev/null @@ -1,317 +0,0 @@ -import _ from 'underscore'; -import {globalEvents} from '../../utils/ajax/specifyApi'; -import {SaveBlockers} from './saveBlockers'; -import {initializeTreeRecord, treeBusinessRules} from './treeBusinessRules'; -import {businessRuleDefs} from './businessRuleDefs'; - -import {formsText} from '../../localization/forms'; -import {formatConjunction} from '../Atoms/Internationalization'; -import {isTreeResource} from '../InitialContext/treeRanks'; -import {idFromUrl} from './resource'; - -var enabled = true; - - globalEvents.on('initResource', (resource) => - enabled && !resource.noBusinessRules ? attachTo(resource) : undefined - ); - - var attachTo = function(resource) { - var mgr; - mgr = resource.businessRuleMgr = new BusinessRuleMgr(resource); - mgr.setupEvents(); - resource.saveBlockers = new SaveBlockers(resource); - if(isTreeResource(resource)) - initializeTreeRecord(resource); - mgr.doCustomInit(); - }; - - function BusinessRuleMgr(resource) { - this.resource = resource; - this.rules = businessRuleDefs[this.resource.specifyModel.name]; - this.pending = Promise.resolve(null); - this.fieldChangePromises = {}; - this.watchers = {}; - } - - _(BusinessRuleMgr.prototype).extend({ - addPromise: function(promise) { - this.pending = Promise.allSettled([this.pending, promise]).then(()=>null); - }, - - setupEvents: function() { - this.resource.on('change', this.changed, this); - this.resource.on('add', this.added, this); - this.resource.on('remove', this.removed, this); - }, - - invokeRule: function(ruleName, fieldName, args) { - var promise = this._invokeRule(ruleName, fieldName, args); - // var resource = this.resource; - // var msg = 'BR ' + ruleName + (fieldName ? '[' + fieldName + '] ': ' ') + 'finished on'; - // promise.then(function(result) { console.debug(msg, resource, {args: args, result: result}); }); - return promise; - }, - _invokeRule: function(ruleName, fieldName, args) { - var rule = this.rules && this.rules[ruleName]; - if (!rule) return Promise.resolve('no rule: ' + ruleName); - if (fieldName) { - rule = rule[fieldName]; - if (!rule) return Promise.resolve('no rule: ' + ruleName + ' for: ' + fieldName); - } - return Promise.resolve(rule.apply(this, args)); - }, - - doCustomInit: function() { - this.addPromise( - this.invokeRule('customInit', null, [this.resource])); - }, - - changed: function(resource) { - if (!resource._fetch && !resource._save) { - _.each(resource.changed, function(__, fieldName) { - this.checkField(fieldName); - }, this); - } - }, - - added: function(resource, collection) { - if (resource.specifyModel && resource.specifyModel.getField('ordinal')) { - resource.set('ordinal', collection.indexOf(resource)); - } - this.addPromise( - this.invokeRule('onAdded', null, [resource, collection])); - }, - - removed: function(resource, collection) { - this.addPromise( - this.invokeRule('onRemoved', null, [resource, collection])); - }, - - checkField: function(fieldName) { - fieldName = fieldName.toLowerCase(); - - const thisCheck = flippedPromise(); - // thisCheck.promise.then(function(result) { console.debug('BR finished checkField', - // {field: fieldName, result: result}); }); - this.addPromise(thisCheck); - - // If another change happens while the previous check is pending, - // that check is superseded by checking the new value. - this.fieldChangePromises[fieldName] && resoleFlippedPromise(this.fieldChangePromises[fieldName], 'superseded'); - this.fieldChangePromises[fieldName] = thisCheck; - - var checks = [ - this.invokeRule('customChecks', fieldName, [this.resource]), - this.checkUnique(fieldName) - ]; - - if(isTreeResource(this.resource)) - checks.push(treeBusinessRules(this.resource, fieldName)); - - Promise.all(checks).then(function(results) { - // Only process these results if the change has not been superseded. - return (thisCheck === this.fieldChangePromises[fieldName]) && - this.processCheckFieldResults(fieldName, results); - }.bind(this)).then(function() { resoleFlippedPromise(thisCheck,'finished'); }); - }, - processCheckFieldResults: function(fieldName, results) { - var resource = this.resource; - return Promise.all(results.map(function(result) { - if (!result) return null; - if (result.valid === false) { - resource.saveBlockers?.add(result.key, fieldName, result.reason); - } else if (result.valid === true) { - resource.saveBlockers?.remove(result.key); - } - return result.action && result.action(); - })); - }, - checkUnique: function(fieldName) { - var _this = this; - var results; - if (this.rules && this.rules.unique && _(this.rules.unique).contains(fieldName)) { - // field value is required to be globally unique. - results = [uniqueIn(null, this.resource, fieldName)]; - } else { - var toOneFields = (this.rules && this.rules.uniqueIn && this.rules.uniqueIn[fieldName]) || []; - if (!_.isArray(toOneFields)) toOneFields = [toOneFields]; - results = _.map(toOneFields, function(def) { - var field = def; - var fieldNames = [fieldName]; - if (typeof def != 'string') { - fieldNames = fieldNames.concat(def.otherfields); - field = def.field; - } - return uniqueIn(field, _this.resource, fieldNames); - }); - } - Promise.all(results).then(function(results) { - _.chain(results).pluck('localDupes').compact().flatten().each(function(dup) { - var event = dup.cid + ':' + fieldName; - if (_this.watchers[event]) return; - _this.watchers[event] = dup.on('change remove', function() { - _this.checkField(fieldName); - }); - }); - }); - return combineUniquenessResults(results).then(function(result) { - // console.debug('BR finished checkUnique for', fieldName, result); - result.key = 'br-uniqueness-' + fieldName; - return result; - }); - } - }); - - - var combineUniquenessResults = function(deferredResults) { - return Promise.all(deferredResults).then(function(results) { - var invalids = _.filter(results, function(result) { return !result.valid; }); - return invalids.length < 1 - ? {valid: true} - : { - valid: false, - reason: formatConjunction(_(invalids).pluck('reason')) - }; - }); - }; - - var getUniqueInInvalidReason = function(parentFldInfo, fldInfo) { - if (fldInfo.length > 1) - return parentFldInfo - ? formsText.valuesOfMustBeUniqueToField({ - values: formatConjunction(fldInfo.map((fld) => fld.label)), - fieldName: parentFldInfo.label, - }) - : formsText.valuesOfMustBeUniqueToDatabase({ - values: formatConjunction(fldInfo.map((fld) => fld.label)) - }); - else - return parentFldInfo - ? formsText.valueMustBeUniqueToField({fieldName: parentFldInfo.label}) - : formsText.valueMustBeUniqueToDatabase(); - }; - - var uniqueIn = function(toOneField, resource, valueFieldArg) { - var valueField = Array.isArray(valueFieldArg) ? valueFieldArg : [valueFieldArg]; - var value = _.map(valueField, function(v) { return resource.get(v);}); - var valueFieldInfo = _.map(valueField, function(v) { return resource.specifyModel.getField(v); }); - var valueIsToOne = _.map(valueFieldInfo, function(fi) { return fi.type === 'many-to-one'; }); - var valueId = _.map(value, function(v, idx) { - if (valueIsToOne[idx]) { - if (_.isNull(v) || typeof v == 'undefined') { - return null; - } else { - return _.isString(v) ? idFromUrl(v) : v.id; - } - } else { - return undefined; - } - }); - - var toOneFieldInfo = toOneField ? resource.specifyModel.getField(toOneField) : undefined; - var valid = { - valid: true - }; - var invalid = { - valid: false, - reason: getUniqueInInvalidReason(toOneFieldInfo, valueFieldInfo) - }; - - var allNullOrUndefinedToOnes = _.reduce(valueId, function(result, v, idx) { - return result && - valueIsToOne[idx] ? _.isNull(valueId[idx]) : false; - }, true); - if (allNullOrUndefinedToOnes) { - return Promise.resolve(valid); - } - - var hasSameVal = function(other, value, valueField, valueIsToOne, valueId) { - if ((other.id != null) && other.id === resource.id) return false; - if (other.cid === resource.cid) return false; - var otherVal = other.get(valueField); - if (valueIsToOne && typeof otherVal != 'undefined' && !(_.isString(otherVal))) { - return Number.parseInt(otherVal.id) === Number.parseInt(valueId); - } else { - return value === otherVal; - } - }; - - var hasSameValues = function(other, values, valueFields, valuesAreToOne, valueIds) { - return _.reduce(values, function(result, val, idx) { - return result && hasSameVal(other, val, valueFields[idx], valuesAreToOne[idx], valueIds[idx]); - }, true); - }; - - if (toOneField != null) { - var haveLocalColl = (resource.collection && resource.collection.related && - toOneFieldInfo.relatedModel === resource.collection.related.specifyModel); - - var localCollection = haveLocalColl ? _.compact(resource.collection.models) : []; - var dupes = _.filter(localCollection, function(other) { return hasSameValues(other, value, valueField, valueIsToOne, valueId); }); - if (dupes.length > 0) { - invalid.localDupes = dupes; - return Promise.resolve(invalid); - } - return resource.rget(toOneField).then(function(related) { - if (!related) return valid; - var filters = {}; - for (var f = 0; f < valueField.length; f++) { - filters[valueField[f]] = valueId[f] || value[f]; - } - var others = new resource.specifyModel.ToOneCollection({ - related: related, - field: toOneFieldInfo, - filters: filters - }); - return others.fetch().then(function() { - var inDatabase = others.chain().compact(); - inDatabase = haveLocalColl ? inDatabase.filter(function(other) { - return !(resource.collection.get(other.id)); - }).value() : inDatabase.value(); - if (_.any(inDatabase, function(other) { return hasSameValues(other, value, valueField, valueIsToOne, valueId); })) { - return invalid; - } else { - return valid; - } - }); - }); - } else { - var filters = {}; - for (var f = 0; f < valueField.length; f++) { - filters[valueField[f]] = valueId[f] || value[f]; - } - var others = new resource.specifyModel.LazyCollection({ - filters: filters - }); - return others.fetch().then(function() { - if (_.any(others.models, function(other) { return hasSameValues(other, value, valueField, valueIsToOne, valueId); })) { - return invalid; - } else { - return valid; - } - }); - } - }; - - -export function enableBusinessRules(e) { - return enabled = e; -} - -// REFACTOR: replace this with flippedPromise util in ../../utils/promise.ts -/** - * A promise that can be resolved from outside the promise - * This is probably an anti-pattern and is included here only for compatability - * with the legacy promise implementation (Q.js) - */ -function flippedPromise() { - const promise = new Promise((resolve) => - globalThis.setTimeout(() => { - promise.resolve = resolve; - }, 0) - ); - return promise; -} - -const resoleFlippedPromise = (promise, ...args) => - globalThis.setTimeout(()=>promise.resolve(...args), 0); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts new file mode 100644 index 00000000000..22422b1d0e7 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -0,0 +1,430 @@ +import { overwriteReadOnly, RA } from '../../utils/types'; +import { AnySchema, AnyTree } from './helperTypes'; +import { SpecifyResource } from './legacyTypes'; +import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; +import { flippedPromise } from '../../utils/promise'; +import { isTreeResource } from '../InitialContext/treeRanks'; +import { initializeTreeRecord, treeBusinessRules } from './treeBusinessRules'; +import _ from 'underscore'; +import { Collection } from './specifyModel'; +import { SaveBlockers } from './saveBlockers'; +import { formatConjunction } from '../Atoms/Internationalization'; +import { formsText } from '../../localization/forms'; +import { LiteralField, Relationship } from './specifyField'; +import { idFromUrl } from './resource'; +import { globalEvents } from '../../utils/ajax/specifyApi'; + +var enabled: boolean = true; + +globalEvents.on('initResource', (resource) => { + enabled && !resource.noBusinessRules + ? attachBusinessRules(resource) + : undefined; +}); + +export function enableBusinessRules(e: boolean) { + return (enabled = e); +} + +export class BusinessRuleMgr { + private readonly resource: SpecifyResource; + private readonly rules: BusinessRuleDefs | undefined; + public pendingPromises: Promise = + Promise.resolve(null); + private fieldChangePromises: { [key: string]: Promise } = + {}; + private watchers = {}; + + public constructor(resource: SpecifyResource) { + this.resource = resource; + this.rules = businessRuleDefs()[this.resource.specifyModel.name]; + } + + private addPromise(promise: Promise): void { + this.pendingPromises = Promise.allSettled([ + this.pendingPromises, + promise, + ]).then(() => null); + } + + private changed(resource: SpecifyResource): void { + if (resource.changed !== undefined) { + Object.keys(resource.changed).forEach((field) => { + this.checkField(field); + }); + } + } + + private removed( + resource: SpecifyResource, + collection: Collection + ): void { + this.addPromise( + this.invokeRule('onRemoved', undefined, [resource, collection]) + ); + } + + public setUpManager(): void { + this.addPromise(this.invokeRule('customInit', undefined, [this.resource])); + if (isTreeResource(this.resource as SpecifyResource)) + initializeTreeRecord(this.resource as SpecifyResource); + + this.resource.on('change', this.changed, this); + this.resource.on('remove', this.removed, this); + } + + public checkField(fieldName: string) { + fieldName = fieldName.toLocaleLowerCase(); + const thisCheck: Promise = flippedPromise(); + this.addPromise(thisCheck); + + this.fieldChangePromises[fieldName] && + resolveFlippedPromise(this.fieldChangePromises[fieldName], 'superseded'); + this.fieldChangePromises[fieldName] = thisCheck; + + var checks = [ + this.invokeRule('fieldChecks', fieldName, [this.resource]), + this.checkUnique(fieldName), + ]; + + if (isTreeResource(this.resource as SpecifyResource)) + checks.push( + treeBusinessRules(this.resource as SpecifyResource, fieldName) + ); + + const _this = this; + + Promise.all(checks) + .then((results) => { + return ( + thisCheck === _this.fieldChangePromises[fieldName] && + _this.processCheckFieldResults(fieldName, results) + ); + }) + .then(() => { + resolveFlippedPromise(thisCheck, 'finished'); + }); + } + + private processCheckFieldResults( + fieldName: string, + results: RA + ): Promise<(void | null)[]> { + return Promise.all( + results.map((result) => { + if (!result) return null; + if (result.valid === false) { + this.resource.saveBlockers?.add(result.key, fieldName, result.reason); + } else { + this.resource.saveBlockers?.remove(result.key); + return result.action && result.action(); + } + }) + ); + } + + private checkUnique(fieldName: string): Promise { + const _this = this; + + var toOneFields: RA = + (this.rules?.uniqueIn && this.rules.uniqueIn[fieldName]) ?? []; + + const results = toOneFields.map((uniqueRule) => { + var field = uniqueRule; + var fieldNames: string[] | null = []; + if (uniqueRule === null) { + fieldNames = null; + } else if (typeof uniqueRule != 'string') { + fieldNames = fieldNames.concat(uniqueRule.otherfields); + field = uniqueRule.field; + } else fieldNames = [fieldName]; + return _this.uniqueIn(field, fieldNames); + }); + + Promise.all(results).then((results) => { + _.chain(results) + .pluck('localDuplicates') + .compact() + .flatten() + .each((duplicate: SpecifyResource) => { + var event = duplicate.cid + ':' + fieldName; + if (_this.watchers[event]) return; + _this.watchers[event] = duplicate.on('change remove', () => { + _this.checkField(fieldName); + }); + }); + }); + return Promise.all(results).then((results) => { + const invalids = results.filter((result) => { + return !result.valid; + }); + return invalids.length < 1 + ? { key: 'br-uniqueness-' + fieldName, valid: true } + : { + key: 'br-uniqueness-' + fieldName, + valid: false, + reason: formatConjunction(_(invalids).pluck('reason')), + }; + }); + } + + private getUniqueInvalidReason( + parentFieldInfo: Relationship | LiteralField, + fieldInfo: RA + ): string { + if (fieldInfo.length > 1) + return parentFieldInfo + ? formsText.valuesOfMustBeUniqueToField({ + values: formatConjunction(fieldInfo.map((fld) => fld.label)), + fieldName: parentFieldInfo.label, + }) + : formsText.valuesOfMustBeUniqueToDatabase({ + values: formatConjunction(fieldInfo.map((fld) => fld.label)), + }); + else + return parentFieldInfo + ? formsText.valueMustBeUniqueToField({ + fieldName: parentFieldInfo.label, + }) + : formsText.valueMustBeUniqueToDatabase(); + } + + private uniqueIn( + toOneField: string | undefined, + fieldNames: RA | string | null + ): Promise { + if (fieldNames === null) { + return Promise.resolve({ + valid: false, + reason: formsText.valueMustBeUniqueToDatabase(), + }); + } + fieldNames = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; + + const fieldValues = fieldNames.map((value) => { + return this.resource.get(value); + }); + + const fieldInfo = fieldNames.map((field) => { + return this.resource.specifyModel.getField(field); + }); + + const fieldIsToOne = fieldInfo.map((field) => { + return field?.type === 'many-to-one'; + }); + + const fieldIds = fieldValues.map((value, index) => { + if (fieldIsToOne[index] != null) { + if (value == null || typeof value == 'undefined') { + return null; + } else { + return typeof value === 'string' ? idFromUrl(value) : value.id; + } + } else return undefined; + }); + + const toOneFieldInfo = + toOneField !== undefined + ? (this.resource.specifyModel.getField(toOneField) as Relationship) + : undefined; + + const allNullOrUndefinedToOnes = _.reduce( + fieldIds, + (result, value, index) => { + return result && fieldIsToOne[index] + ? _.isNull(fieldIds[index]) + : false; + }, + true + ); + + const invalidResponse: BusinessRuleResult & { + localDuplicates?: RA>; + } = { + valid: false, + reason: + toOneFieldInfo !== undefined + ? this.getUniqueInvalidReason(toOneFieldInfo, fieldInfo) + : '', + }; + + if (allNullOrUndefinedToOnes) return Promise.resolve({ valid: true }); + + const hasSameValues = (other: SpecifyResource): boolean => { + const hasSameValue = ( + fieldValue: string | number | null, + fieldName: string, + fieldIsToOne: boolean, + fieldId: string + ): boolean => { + if (other.id != null && other.id === this.resource.id) return false; + if (other.cid === this.resource.cid) return false; + const otherValue = other.get(fieldName); + if ( + fieldIsToOne && + typeof otherValue != 'undefined' && + typeof otherValue !== 'string' + ) { + return Number.parseInt(otherValue?.id) === Number.parseInt(fieldId); + } else return fieldValue === otherValue; + }; + + return _.reduce( + fieldValues, + (result, fieldValue, index) => { + return ( + result && + hasSameValue( + fieldValue, + fieldNames[index], + fieldIsToOne[index], + fieldIds[index] + ) + ); + }, + true + ); + }; + + if (toOneField != null) { + const hasLocalCollection = + this.resource.collection && + this.resource.collection.related && + toOneFieldInfo?.relatedModel === + this.resource.collection.related?.specifyModel; + + var localCollection = hasLocalCollection + ? this.resource.collection.models.filter((resource) => { + resource !== undefined; + }) + : []; + + var duplicates = localCollection.filter((resource) => { + return hasSameValues(resource); + }); + + if (duplicates.length > 0) { + invalidResponse.localDuplicates = duplicates; + return Promise.resolve(invalidResponse); + } + + return this.resource.rget(toOneField).then((related) => { + if (!related) return Promise.resolve({ valid: true }); + var filters = {}; + for (var f = 0; f < fieldNames.length; f++) { + filters[fieldNames[f]] = fieldIds[f] || fieldValues[f]; + } + const others = new this.resource.specifyModel.ToOneCollection({ + related: related, + field: toOneFieldInfo, + filters: filters, + }); + + return others.fetch().then(() => { + var inDatabase = others.chain().compact(); + inDatabase = hasLocalCollection + ? inDatabase + .filter((other: SpecifyResource) => { + return !this.resource.collection.get(other.id); + }) + .value() + : inDatabase.value(); + + if ( + inDatabase.some((other) => { + return hasSameValues(other); + }) + ) { + return invalidResponse; + } else { + return { valid: true }; + } + }); + }); + } else { + var filters = {}; + + for (var f = 0; f < fieldNames.length; f++) { + filters[fieldNames[f]] = fieldIds[f] || fieldValues[f]; + } + const others = new this.resource.specifyModel.LazyCollection({ + filters: filters, + }); + return others.fetch().then(() => { + if ( + others.models.some((other: SpecifyResource) => { + return hasSameValues(other); + }) + ) { + return invalidResponse; + } else { + return { valid: true }; + } + }); + } + } + + private invokeRule( + ruleName: keyof BusinessRuleDefs, + fieldName?: string, + args: RA + ): Promise { + if (this.rules === undefined) { + return Promise.resolve(undefined); + } + var rule = this.rules[ruleName]; + const isValid = + (rule && typeof rule == 'function') || + (fieldName && + rule && + rule[fieldName] && + typeof rule[fieldName] == 'function'); + + if (!isValid) return Promise.resolve(undefined); + + if (fieldName !== undefined) { + rule = rule[fieldName]; + if (!rule) { + return Promise.resolve({ + key: 'invalidRule', + valid: false, + reason: 'no rule: ' + ruleName + ' for: ' + fieldName, + }); + } + } + if (rule == undefined) { + return Promise.resolve({ + key: 'invalidRule', + valid: false, + reason: 'no rule: ' + ruleName + ' for: ' + fieldName, + }); + } + return Promise.resolve(rule.apply(this, args)); + } +} + +export function attachBusinessRules( + resource: SpecifyResource +): void { + const businessRuleManager = new BusinessRuleMgr(resource); + overwriteReadOnly(resource, 'businessRuleMgr', businessRuleManager); + overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); + businessRuleManager.setUpManager(); +} + +export type BusinessRuleResult = { + readonly key?: string; +} & ( + | { + readonly valid: true; + readonly action?: () => void; + } + | { readonly valid: false; readonly reason: string } +); + +const resolveFlippedPromise = ( + promise: Promise, + ...args: RA +): void => { + globalThis.setTimeout(() => promise.resolve(...args), 0); +}; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index f4b406e44c4..310dc18a52f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -10,7 +10,9 @@ import { CommonFields, SerializedModel, SerializedResource, + TableFields, } from './helperTypes'; +import { BusinessRuleMgr } from './businessRules'; /* * FEATURE: need to improve the typing to handle the following: @@ -29,13 +31,13 @@ export type SpecifyResource = { readonly saveBlockers?: Readonly>; readonly parent?: SpecifyResource; readonly noBusinessRules: boolean; - readonly collection: { - readonly related: SpecifyResource; - }; - readonly businessRuleMgr?: { - readonly pending: Promise; - readonly checkField: (fieldName: string) => Promise; - }; + readonly _fetch?: unknown; + readonly _save?: unknown; + readonly changed?: { [FIELDNAME in TableFields]?: string | number }; + readonly collection: Collection; + readonly businessRuleMgr?: + | BusinessRuleMgr + | BusinessRuleMgr; /* * Shorthand method signature is used to prevent * https://github.com/microsoft/TypeScript/issues/48339 @@ -184,7 +186,11 @@ export type SpecifyResource = { placeInSameHierarchy( resource: SpecifyResource ): SpecifyResource | undefined; - on(eventName: string, callback: (...args: RA) => void): void; + on( + eventName: string, + callback: (...args: RA) => void, + thisArg?: any + ): void; once(eventName: string, callback: (...args: RA) => void): void; off(eventName?: string, callback?: (...args: RA) => void): void; trigger(eventName: string, ...args: RA): void; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.ts index 58666bbbeb6..d3925c3be17 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.ts @@ -5,7 +5,7 @@ import type { SpecifyResource } from './legacyTypes'; import type { R, RA } from '../../utils/types'; -import {AnySchema} from './helperTypes'; +import { AnySchema } from './helperTypes'; /* * Propagate a save blocker even for independent resources, because diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts index 036b73f7d0d..bc254a3b361 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts @@ -96,6 +96,8 @@ export type Collection = { getTotalCount(): Promise; // eslint-disable-next-line @typescript-eslint/naming-convention toJSON>(): RA; + any(callback: (others: SpecifyResource) => boolean): boolean; + each(callback: (other: SpecifyResource) => void): void; add(resource: RA> | SpecifyResource): void; remove(resource: SpecifyResource): void; fetch(filter?: { readonly limit: number }): Promise>; @@ -403,7 +405,7 @@ export class SpecifyModel { .map((fieldName) => this.getField(fieldName)) .find( (field): field is Relationship => - field?.isRelationship === true && (!relationshipIsToMany(field)) + field?.isRelationship === true && !relationshipIsToMany(field) ); } diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 7912728c4cf..f99010b6e74 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -5,6 +5,7 @@ import type { SpecifyResource } from './legacyTypes'; import { treeText } from '../../localization/tree'; import { formatUrl } from '../Router/queryString'; import { AnyTree } from './helperTypes'; +import { BusinessRuleResult } from './businessRules'; export const initializeTreeRecord = ( resource: SpecifyResource @@ -23,16 +24,6 @@ export const treeBusinessRules = async ( ? predictFullName(resource, false) : undefined; -export type BusinessRuleResult = { - readonly key: string; -} & ( - | { - readonly valid: true; - readonly action: () => void; - } - | { readonly valid: false; readonly reason: string } -); - const predictFullName = async ( resource: SpecifyResource, reportBadStructure: boolean diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index a9c87f857ec..cc1f0abb4a2 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -144,40 +144,42 @@ export function SaveButton({ } loading( - (resource.businessRuleMgr?.pending ?? Promise.resolve()).then(() => { - const blockingResources = Array.from( - resource.saveBlockers?.blockingResources ?? [] - ); - blockingResources.forEach((resource) => - resource.saveBlockers?.fireDeferredBlockers() - ); - if (blockingResources.length > 0) { - setShowBlockedDialog(true); - return; - } + (resource.businessRuleMgr?.pendingPromises ?? Promise.resolve()).then( + () => { + const blockingResources = Array.from( + resource.saveBlockers?.blockingResources ?? [] + ); + blockingResources.forEach((resource) => + resource.saveBlockers?.fireDeferredBlockers() + ); + if (blockingResources.length > 0) { + setShowBlockedDialog(true); + return; + } - /* - * Save process is canceled if false was returned. This also allows to - * implement custom save behavior - */ - if (handleSaving?.(unsetUnloadProtect) === false) return; + /* + * Save process is canceled if false was returned. This also allows to + * implement custom save behavior + */ + if (handleSaving?.(unsetUnloadProtect) === false) return; - setIsSaving(true); - return resource - .save({ onSaveConflict: hasSaveConflict }) - .catch((error_) => - // FEATURE: if form save fails, should make the error message dismissable (if safe) - Object.getOwnPropertyDescriptor(error_ ?? {}, 'handledBy') - ?.value === hasSaveConflict - ? undefined - : error(error_) - ) - .finally(() => { - unsetUnloadProtect(); - handleSaved?.(); - setIsSaving(false); - }); - }) + setIsSaving(true); + return resource + .save({ onSaveConflict: hasSaveConflict }) + .catch((error_) => + // FEATURE: if form save fails, should make the error message dismissable (if safe) + Object.getOwnPropertyDescriptor(error_ ?? {}, 'handledBy') + ?.value === hasSaveConflict + ? undefined + : error(error_) + ) + .finally(() => { + unsetUnloadProtect(); + handleSaved?.(); + setIsSaving(false); + }); + } + ) ); } From 2fc31dc87d365340884afde0223596913ce96992 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Sun, 12 Feb 2023 21:39:28 -0600 Subject: [PATCH 03/50] Refactor interaction business rules --- .../components/DataModel/businessRuleDefs.ts | 70 ++++------ .../lib/components/DataModel/businessRules.ts | 24 ++-- .../lib/components/DataModel/helperTypes.ts | 15 ++- .../DataModel/interactionBusinessRules.js | 55 -------- .../DataModel/interactionBusinessRules.ts | 121 ++++++++++++++++++ .../components/DataModel/uniquness_rules.json | 4 +- 6 files changed, 179 insertions(+), 110 deletions(-) delete mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index ed79d6e4471..68300ddb04a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -1,7 +1,13 @@ import { f } from '../../utils/functools'; import { overwriteReadOnly } from '../../utils/types'; import { AnySchema, TableFields } from './helperTypes'; -import { interactionBusinessRules } from './interactionBusinessRules'; +import { + checkPrepAvailability, + getTotalLoaned, + getTotalResolved, + interactionCache, + updateLoanPrep, +} from './interactionBusinessRules'; import { SpecifyResource } from './legacyTypes'; import { BusinessRuleResult } from './businessRules'; import { schema } from './schema'; @@ -183,14 +189,14 @@ export const businessRuleDefs = f.store( GiftPreparation: { fieldChecks: { quantity: (iprep: SpecifyResource): void => { - interactionBusinessRules.checkPrepAvailability(iprep); + checkPrepAvailability(iprep); }, }, }, LoanPreparation: { fieldChecks: { quantity: (iprep: SpecifyResource): void => { - interactionBusinessRules.checkPrepAvailability(iprep); + checkPrepAvailability(iprep); }, }, }, @@ -199,15 +205,11 @@ export const businessRuleDefs = f.store( loanReturnPrep: SpecifyResource, collection: Collection ): void => { - interactionBusinessRules.updateLoanPrep(loanReturnPrep, collection); + updateLoanPrep(loanReturnPrep, collection); }, customInit: ( resource: SpecifyResource ): void => { - interactionBusinessRules.totalLoaned = undefined; - interactionBusinessRules.totalResolved = undefined; - interactionBusinessRules.returned = undefined; - interactionBusinessRules.resolved = undefined; resource.get('quantityReturned') == null && resource.set('quantityReturned', 0); resource.get('quantityResolved') == null && @@ -216,77 +218,61 @@ export const businessRuleDefs = f.store( fieldChecks: { quantityreturned: ( loanReturnPrep: SpecifyResource - ): Promise | void => { + ): void => { var returned = loanReturnPrep.get('quantityReturned'); - var previousReturned = interactionBusinessRules.previousReturned[ + var previousReturned = interactionCache().previousReturned[ Number(loanReturnPrep.cid) ] - ? interactionBusinessRules.previousReturned[ - Number(loanReturnPrep.cid) - ] + ? interactionCache().previousReturned[Number(loanReturnPrep.cid)] : 0; if (returned !== null && returned != previousReturned) { var delta = returned - previousReturned; var resolved = loanReturnPrep.get('quantityResolved'); - var totalLoaned = - interactionBusinessRules.getTotalLoaned(loanReturnPrep); - var totalResolved = - interactionBusinessRules.getTotalResolved(loanReturnPrep); + var totalLoaned = getTotalLoaned(loanReturnPrep); + var totalResolved = getTotalResolved(loanReturnPrep); var max = totalLoaned - totalResolved; if (resolved !== null && delta + resolved > max) { loanReturnPrep.set('quantityReturned', previousReturned); } else { resolved = loanReturnPrep.get('quantityResolved')! + delta; - interactionBusinessRules.previousResolved[ + interactionCache().previousResolved[ Number(loanReturnPrep.cid) ] = resolved; loanReturnPrep.set('quantityResolved', resolved); } - interactionBusinessRules.previousReturned[ - Number(loanReturnPrep.cid) - ] = loanReturnPrep.get('quantityReturned'); - interactionBusinessRules.updateLoanPrep( - loanReturnPrep, - loanReturnPrep.collection - ); + interactionCache().previousReturned[Number(loanReturnPrep.cid)] = + loanReturnPrep.get('quantityReturned'); + updateLoanPrep(loanReturnPrep, loanReturnPrep.collection); } }, quantityresolved: ( loanReturnPrep: SpecifyResource - ): Promise | void => { + ): void => { var resolved = loanReturnPrep.get('quantityResolved'); - var previousResolved = interactionBusinessRules.previousResolved[ + var previousResolved = interactionCache().previousResolved[ Number(loanReturnPrep.cid) ] - ? interactionBusinessRules.previousResolved[ - Number(loanReturnPrep.cid) - ] + ? interactionCache().previousResolved[Number(loanReturnPrep.cid)] : 0; if (resolved != previousResolved) { var returned = loanReturnPrep.get('quantityReturned'); - var totalLoaned = - interactionBusinessRules.getTotalLoaned(loanReturnPrep); - var totalResolved = - interactionBusinessRules.getTotalResolved(loanReturnPrep); + var totalLoaned = getTotalLoaned(loanReturnPrep); + var totalResolved = getTotalResolved(loanReturnPrep); var max = totalLoaned - totalResolved; if (resolved !== null && returned !== null) { if (resolved > max) { loanReturnPrep.set('quantityResolved', previousResolved); } if (resolved < returned) { - interactionBusinessRules.previousReturned[ + interactionCache().previousReturned[ Number(loanReturnPrep.cid) ] = resolved; loanReturnPrep.set('quantityReturned', resolved); } } - interactionBusinessRules.previousResolved[ - Number(loanReturnPrep.cid) - ] = loanReturnPrep.get('quantityResolved'); - interactionBusinessRules.updateLoanPrep( - loanReturnPrep, - loanReturnPrep.collection - ); + interactionCache().previousResolved[Number(loanReturnPrep.cid)] = + loanReturnPrep.get('quantityResolved'); + updateLoanPrep(loanReturnPrep, loanReturnPrep.collection); } }, }, diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 22422b1d0e7..bd9b080f4cb 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -13,6 +13,7 @@ import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; import { globalEvents } from '../../utils/ajax/specifyApi'; +import { Tables } from './types'; var enabled: boolean = true; @@ -37,7 +38,8 @@ export class BusinessRuleMgr { public constructor(resource: SpecifyResource) { this.resource = resource; - this.rules = businessRuleDefs()[this.resource.specifyModel.name]; + this.rules = + businessRuleDefs()[this.resource.specifyModel.name as keyof Tables]; } private addPromise(promise: Promise): void { @@ -73,12 +75,12 @@ export class BusinessRuleMgr { this.resource.on('remove', this.removed, this); } - public checkField(fieldName: string) { + public async checkField(fieldName: string) { fieldName = fieldName.toLocaleLowerCase(); const thisCheck: Promise = flippedPromise(); this.addPromise(thisCheck); - this.fieldChangePromises[fieldName] && + (await this.fieldChangePromises[fieldName]) && resolveFlippedPromise(this.fieldChangePromises[fieldName], 'superseded'); this.fieldChangePromises[fieldName] = thisCheck; @@ -113,10 +115,13 @@ export class BusinessRuleMgr { return Promise.all( results.map((result) => { if (!result) return null; + if (result.key == undefined) { + return null; + } if (result.valid === false) { - this.resource.saveBlockers?.add(result.key, fieldName, result.reason); + this.resource.saveBlockers!.add(result.key, fieldName, result.reason); } else { - this.resource.saveBlockers?.remove(result.key); + this.resource.saveBlockers!.remove(result.key); return result.action && result.action(); } }) @@ -125,7 +130,6 @@ export class BusinessRuleMgr { private checkUnique(fieldName: string): Promise { const _this = this; - var toOneFields: RA = (this.rules?.uniqueIn && this.rules.uniqueIn[fieldName]) ?? []; @@ -276,7 +280,7 @@ export class BusinessRuleMgr { result && hasSameValue( fieldValue, - fieldNames[index], + fieldNames![index], fieldIsToOne[index], fieldIds[index] ) @@ -311,8 +315,8 @@ export class BusinessRuleMgr { return this.resource.rget(toOneField).then((related) => { if (!related) return Promise.resolve({ valid: true }); var filters = {}; - for (var f = 0; f < fieldNames.length; f++) { - filters[fieldNames[f]] = fieldIds[f] || fieldValues[f]; + for (var f = 0; f < fieldNames!.length; f++) { + filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; } const others = new this.resource.specifyModel.ToOneCollection({ related: related, @@ -407,8 +411,8 @@ export function attachBusinessRules( resource: SpecifyResource ): void { const businessRuleManager = new BusinessRuleMgr(resource); - overwriteReadOnly(resource, 'businessRuleMgr', businessRuleManager); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); + overwriteReadOnly(resource, 'businessRuleMgr', businessRuleManager); businessRuleManager.setUpManager(); } diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts index c76d6047986..80db4835d0a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts @@ -1,5 +1,5 @@ import type { IR, RA } from '../../utils/types'; -import type { Tables } from './types'; +import type { Preparation, Tables } from './types'; /** * Represents a schema for any table @@ -55,6 +55,19 @@ export type AnyTree = Extract< } >; +export type AnyInteractionPreparation = Extract< + Tables[keyof Tables], + { + readonly toOneIndependent: { + readonly preparation: Preparation | null; + }; + } & { + readonly fields: { + readonly quantity: number | null; + }; + } +>; + /** * Filter table schemas down to schemas for tables whose names end with a * particular substring diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js deleted file mode 100644 index 924a1accbc9..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js +++ /dev/null @@ -1,55 +0,0 @@ -import _ from 'underscore'; -import {schema} from './schema'; -import {getPrepAvailability} from '../../utils/ajax/specifyApi'; -import {idFromUrl} from './resource'; - -export const interactionBusinessRules = { - previousReturned: [], - previousResolved: [], - getTotalLoaned: function(loanreturnprep) { - if (typeof this.totalLoaned == 'undefined') { - if (loanreturnprep.collection) { - this.totalLoaned = loanreturnprep.collection.related.get('quantity'); - } - } - return this.totalLoaned; - }, - getTotalResolved: function(loanreturnprep) { - //probably could just check preparation since its quantities are updated while loanreturnprep subview is open - //But for now, iterate other returns - return loanreturnprep.collection ? _.reduce(loanreturnprep.collection.models, function(sum, m) { - return m.cid != loanreturnprep.cid ? sum + m.get('quantityresolved') : sum; - }, 0) : loanreturnprep.get('quantityresolved'); - }, - - checkPrepAvailability: function(interactionprep) { - if (interactionprep && interactionprep.get('preparation')) { - //return interactionprep.get('preparation').get('CountAmt'); - var prepuri = interactionprep.get('preparation'); - var pmod = schema.models.Preparation; - const prepId = idFromUrl(prepuri); - var iprepId = interactionprep.isNew() ? undefined : interactionprep.get('id'); - var iprepName = interactionprep.isNew() ? undefined : interactionprep.specifyModel.name; - getPrepAvailability(prepId, iprepId, iprepName).then(function(available) { - if (typeof available != 'undefined' && Number(available[0]) < interactionprep.get('quantity')) { - interactionprep.set('quantity', Number(available[0])); - } - }); - } - }, - - updateLoanPrep: function(loanreturnprep, collection) { - if (collection && collection.related.specifyModel.name == 'LoanPreparation') { - var sums = _.reduce(collection.models, function(memo, lrp) { - memo.returned += lrp.get('quantityreturned'); - memo.resolved += lrp.get('quantityresolved'); - return memo; - }, {returned: 0, resolved: 0}); - var loanprep = collection.related; - loanprep.set('quantityreturned', sums.returned); - loanprep.set('quantityresolved', sums.resolved); - loanprep.set('isresolved', sums.resolved == loanprep.get('quantity')); - } - } - }; - diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts new file mode 100644 index 00000000000..bc3b1f8b03d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -0,0 +1,121 @@ +import _ from 'underscore'; +import { formsText } from '../../localization/forms'; +import { getPrepAvailability } from '../../utils/ajax/specifyApi'; +import { f } from '../../utils/functools'; +import { AnyInteractionPreparation } from './helperTypes'; +import { SpecifyResource } from './legacyTypes'; +import { fetchResource, idFromUrl } from './resource'; +import { Collection } from './specifyModel'; +import { LoanPreparation, LoanReturnPreparation } from './types'; + +type InteractionBusinessRules = { + previousReturned: { [prepCid: number]: number }; + previousResolved: { [prepCid: number]: number }; +}; + +export var interactionCache = f.store( + (): InteractionBusinessRules => ({ + previousReturned: {}, + previousResolved: {}, + }) +); + +export const getTotalLoaned = ( + loanReturnPrep: SpecifyResource +): number | undefined => { + return loanReturnPrep.collection + ? loanReturnPrep.collection.related?.get('quantity') + : undefined; +}; + +export const updateLoanPrep = ( + loanReturnPrep: SpecifyResource, + collection: Collection +) => { + if ( + collection != undefined && + collection.related?.specifyModel.name == 'LoanPreparation' + ) { + const sums = _.reduce( + collection.models, + (memo: { returned: number; resolved: number }, lrp) => { + memo.returned += lrp.get('quantityReturned'); + memo.resolved += lrp.get('quantityResolved'); + return memo; + }, + { returned: 0, resolved: 0 } + ); + const loanPrep: SpecifyResource = collection.related; + loanPrep.set('quantityReturned', sums.returned); + loanPrep.set('quantityResolved', sums.resolved); + } +}; + +export const getTotalResolved = ( + loanReturnPrep: SpecifyResource +) => { + return loanReturnPrep.collection + ? _.reduce( + loanReturnPrep.collection.models, + (sum, loanPrep) => { + return loanPrep.cid != loanReturnPrep.cid + ? sum + loanPrep.get('quantityResolved') + : sum; + }, + 0 + ) + : loanReturnPrep.get('quantityResolved'); +}; + +const updatePrepBlockers = ( + interactionPrep: SpecifyResource +): Promise => { + const prepUri = interactionPrep.get('preparation'); + const prepId = idFromUrl(prepUri); + return fetchResource('Preparation', prepId) + .then((preparation) => { + return preparation.countAmt >= interactionPrep.get('quantity'); + }) + .then((isValid) => { + if (!isValid) { + if (interactionPrep.saveBlockers?.blockers) + interactionPrep.saveBlockers?.add( + 'parseError-quantity', + 'quantity', + formsText.invalidValue() + ); + } else { + interactionPrep.saveBlockers?.remove('parseError-quantity'); + } + }); +}; + +export const checkPrepAvailability = ( + interactionPrep: SpecifyResource +) => { + if ( + interactionPrep != undefined && + interactionPrep.get('preparation') != undefined + ) { + const prepUri = interactionPrep.get('preparation'); + const prepId = idFromUrl(prepUri); + updatePrepBlockers(interactionPrep); + const interactionId = interactionPrep.isNew() + ? undefined + : interactionPrep.get('id'); + const interactionModelName = interactionPrep.isNew() + ? undefined + : interactionPrep.specifyModel.name; + + getPrepAvailability(prepId!, interactionId, interactionModelName!).then( + (available) => { + if ( + typeof available != 'undefined' && + Number(available[0]) < interactionPrep.get('quantity') + ) { + interactionPrep.set('quantity', Number(available[0])); + } + } + ); + } +}; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index 0a4816fb2dd..c74ecd1af11 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -168,8 +168,8 @@ ] }, "Preparation":{ - "barCode":[ - "collection" + "barcode":[ + "collectionmemberid" ] }, "PrepType":{ From c4772c1cb6348714889d90fe608746585f5e1dd7 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Feb 2023 10:10:56 -0600 Subject: [PATCH 04/50] Fix remaining bugs --- .../components/DataModel/businessRuleDefs.ts | 8 +- .../lib/components/DataModel/businessRules.ts | 108 +- .../lib/components/DataModel/collectionApi.js | 283 ++-- .../DataModel/interactionBusinessRules.ts | 2 +- .../lib/components/DataModel/legacyTypes.ts | 22 +- .../lib/components/DataModel/resourceApi.js | 1252 +++++++++-------- 6 files changed, 910 insertions(+), 765 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 68300ddb04a..1a6430e9bef 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -134,7 +134,7 @@ export const businessRuleDefs = f.store( fieldChecks: { taxon: ( determination: SpecifyResource - ): Promise | void | undefined => { + ): Promise => { return determination .rget('taxon', true) .then((taxon: SpecifyResource | null) => @@ -145,9 +145,7 @@ export const businessRuleDefs = f.store( determination.set('preferredTaxon', null); }, } - : (function recur( - taxon: SpecifyResource - ): BusinessRuleResult { + : (function recur(taxon): BusinessRuleResult { return taxon.get('acceptedTaxon') == null ? { valid: true, @@ -155,7 +153,7 @@ export const businessRuleDefs = f.store( determination.set('preferredTaxon', taxon); }, } - : taxon.rget('acceptedtaxon', true).then(recur); + : taxon.rget('acceptedTaxon', true).then(recur); })(taxon) ); }, diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index bd9b080f4cb..3ee93db21c7 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -13,7 +13,6 @@ import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; import { globalEvents } from '../../utils/ajax/specifyApi'; -import { Tables } from './types'; var enabled: boolean = true; @@ -32,14 +31,14 @@ export class BusinessRuleMgr { private readonly rules: BusinessRuleDefs | undefined; public pendingPromises: Promise = Promise.resolve(null); - private fieldChangePromises: { [key: string]: Promise } = - {}; - private watchers = {}; + private fieldChangePromises: { + [key: string]: Promise | Promise; + } = {}; + private watchers: { [key: string]: () => void } = {}; public constructor(resource: SpecifyResource) { this.resource = resource; - this.rules = - businessRuleDefs()[this.resource.specifyModel.name as keyof Tables]; + this.rules = businessRuleDefs()[this.resource.specifyModel.name]; } private addPromise(promise: Promise): void { @@ -75,13 +74,13 @@ export class BusinessRuleMgr { this.resource.on('remove', this.removed, this); } - public async checkField(fieldName: string) { + public checkField(fieldName: string) { fieldName = fieldName.toLocaleLowerCase(); const thisCheck: Promise = flippedPromise(); this.addPromise(thisCheck); - (await this.fieldChangePromises[fieldName]) && - resolveFlippedPromise(this.fieldChangePromises[fieldName], 'superseded'); + this.fieldChangePromises[fieldName] !== undefined && + this.fieldChangePromises[fieldName].resolve('superseded'); this.fieldChangePromises[fieldName] = thisCheck; var checks = [ @@ -115,20 +114,22 @@ export class BusinessRuleMgr { return Promise.all( results.map((result) => { if (!result) return null; - if (result.key == undefined) { + if (result.key === undefined) { + if (result.valid) + return typeof result.action === 'function' ? result.action() : null; return null; } if (result.valid === false) { this.resource.saveBlockers!.add(result.key, fieldName, result.reason); } else { this.resource.saveBlockers!.remove(result.key); - return result.action && result.action(); + return typeof result.action === 'function' ? result.action() : null; } }) ); } - private checkUnique(fieldName: string): Promise { + private async checkUnique(fieldName: string): Promise { const _this = this; var toOneFields: RA = (this.rules?.uniqueIn && this.rules.uniqueIn[fieldName]) ?? []; @@ -151,11 +152,14 @@ export class BusinessRuleMgr { .compact() .flatten() .each((duplicate: SpecifyResource) => { - var event = duplicate.cid + ':' + fieldName; - if (_this.watchers[event]) return; - _this.watchers[event] = duplicate.on('change remove', () => { - _this.checkField(fieldName); - }); + const event = duplicate.cid + ':' + fieldName; + if (_this.watchers[event]) { + return; + } + _this.watchers[event] = () => + duplicate.on('change remove', () => { + _this.checkField(fieldName); + }); }); }); return Promise.all(results).then((results) => { @@ -247,7 +251,7 @@ export class BusinessRuleMgr { } = { valid: false, reason: - toOneFieldInfo !== undefined + toOneFieldInfo !== undefined && fieldInfo !== undefined ? this.getUniqueInvalidReason(toOneFieldInfo, fieldInfo) : '', }; @@ -299,7 +303,7 @@ export class BusinessRuleMgr { var localCollection = hasLocalCollection ? this.resource.collection.models.filter((resource) => { - resource !== undefined; + return resource !== undefined; }) : []; @@ -312,39 +316,41 @@ export class BusinessRuleMgr { return Promise.resolve(invalidResponse); } - return this.resource.rget(toOneField).then((related) => { - if (!related) return Promise.resolve({ valid: true }); - var filters = {}; - for (var f = 0; f < fieldNames!.length; f++) { - filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; - } - const others = new this.resource.specifyModel.ToOneCollection({ - related: related, - field: toOneFieldInfo, - filters: filters, - }); - - return others.fetch().then(() => { - var inDatabase = others.chain().compact(); - inDatabase = hasLocalCollection - ? inDatabase - .filter((other: SpecifyResource) => { - return !this.resource.collection.get(other.id); - }) - .value() - : inDatabase.value(); - - if ( - inDatabase.some((other) => { - return hasSameValues(other); - }) - ) { - return invalidResponse; - } else { - return { valid: true }; + return this.resource + .rget(toOneField) + .then((related: SpecifyResource) => { + if (!related) return Promise.resolve({ valid: true }); + var filters = {}; + for (var f = 0; f < fieldNames!.length; f++) { + filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; } + const others = new this.resource.specifyModel.ToOneCollection({ + related: related, + field: toOneFieldInfo, + filters: filters, + }); + + return others.fetch().then(() => { + var inDatabase = others.chain().compact(); + inDatabase = hasLocalCollection + ? inDatabase + .filter((other: SpecifyResource) => { + return !this.resource.collection.get(other.id); + }) + .value() + : inDatabase.value(); + + if ( + inDatabase.some((other) => { + return hasSameValues(other); + }) + ) { + return invalidResponse; + } else { + return { valid: true }; + } + }); }); - }); } else { var filters = {}; @@ -370,7 +376,7 @@ export class BusinessRuleMgr { private invokeRule( ruleName: keyof BusinessRuleDefs, - fieldName?: string, + fieldName: string | undefined, args: RA ): Promise { if (this.rules === undefined) { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js index 65e044f295f..d0316395220 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js @@ -1,138 +1,165 @@ import _ from 'underscore'; -import {Backbone} from './backbone'; -import {assert} from '../Errors/assert'; -import {hasHierarchyField} from './schema'; +import { Backbone } from './backbone'; +import { assert } from '../Errors/assert'; +import { hasHierarchyField } from './schema'; + +var Base = Backbone.Collection.extend({ + __name__: 'CollectionBase', + getTotalCount() { + return Promise.resolve(this.length); + }, +}); + +function notSupported() { + throw new Error('method is not supported'); +} + +async function fakeFetch() { + return this; +} + +function setupToOne(collection, options) { + collection.field = options.field; + collection.related = options.related; + + assert( + collection.field.model === collection.model.specifyModel, + "field doesn't belong to model" + ); + assert( + collection.field.relatedModel === collection.related.specifyModel, + 'field is not to related resource' + ); +} + +export const DependentCollection = Base.extend({ + __name__: 'DependentCollectionBase', + constructor(options, models = []) { + assert(_.isArray(models)); + Base.call(this, models, options); + }, + initialize(_models, options) { + this.on( + 'add remove', + function () { + // Warning: changing a collection record does not trigger a + // change event in the parent (though it probably should) + this.trigger('saverequired'); + }, + this + ); + + setupToOne(this, options); + + // If the id of the related resource changes, we go through and update + // all the objects that point to it with the new pointer. + // This is to support having collections of objects attached to + // newly created resources that don't have ids yet. When the + // resource is saved, the related objects can have their foreign keys + // set correctly. + this.related.on( + 'change:id', + function () { + var relatedUrl = this.related.url(); + _.chain(this.models) + .compact() + .invoke('set', this.field.name, relatedUrl); + }, + this + ); + }, + isComplete() { + return true; + }, + fetch: fakeFetch, + sync: notSupported, + create: notSupported, +}); + +export const LazyCollection = Base.extend({ + __name__: 'LazyCollectionBase', + _neverFetched: true, + constructor(options) { + options || (options = {}); + Base.call(this, null, options); + this.filters = options.filters || {}; + this.domainfilter = + !!options.domainfilter && + (typeof this.model?.specifyModel !== 'object' || + hasHierarchyField(this.model.specifyModel)); + }, + url() { + return '/api/specify/' + this.model.specifyModel.name.toLowerCase() + '/'; + }, + isComplete() { + return this.length === this._totalCount; + }, + parse(resp) { + var objects; + if (resp.meta) { + this._totalCount = resp.meta.total_count; + objects = resp.objects; + } else { + console.warn("expected 'meta' in response"); + this._totalCount = resp.length; + objects = resp; + } + return objects; + }, + async fetch(options) { + this._neverFetched = false; -var Base = Backbone.Collection.extend({ - __name__: "CollectionBase", - getTotalCount() { return Promise.resolve(this.length); } - }); + if (this._fetch) return this._fetch; + else if (this.isComplete() || this.related?.isNew()) + return Promise.resolve(this); - function notSupported() { throw new Error("method is not supported"); } + if (this.isComplete()) + console.error('fetching for already filled collection'); - async function fakeFetch() { - return this; - } + options || (options = {}); - function setupToOne(collection, options) { - collection.field = options.field; - collection.related = options.related; + options.update = true; + options.remove = false; + options.silent = true; + assert(options.at == null); - assert(collection.field.model === collection.model.specifyModel, "field doesn't belong to model"); - assert(collection.field.relatedModel === collection.related.specifyModel, "field is not to related resource"); - } + options.data = + options.data || + _.extend({ domainfilter: this.domainfilter }, this.filters); + options.data.offset = this.length; - export const DependentCollection = Base.extend({ - __name__: "DependentCollectionBase", - constructor(options, models=[]) { - assert(_.isArray(models)); - Base.call(this, models, options); - }, - initialize(_models, options) { - this.on('add remove', function() { - // Warning: changing a collection record does not trigger a - // change event in the parent (though it probably should) - this.trigger('saverequired'); - }, this); - - setupToOne(this, options); - - // If the id of the related resource changes, we go through and update - // all the objects that point to it with the new pointer. - // This is to support having collections of objects attached to - // newly created resources that don't have ids yet. When the - // resource is saved, the related objects can have their foreign keys - // set correctly. - this.related.on('change:id', function() { - var relatedUrl = this.related.url(); - _.chain(this.models).compact().invoke('set', this.field.name, relatedUrl); - }, this); - }, - isComplete() { return true; }, - fetch: fakeFetch, - sync: notSupported, - create: notSupported + _(options).has('limit') && (options.data.limit = options.limit); + this._fetch = Backbone.Collection.prototype.fetch.call(this, options); + return this._fetch.then(() => { + this._fetch = null; + return this; }); - - export const LazyCollection = Base.extend({ - __name__: "LazyCollectionBase", - _neverFetched: true, - constructor(options) { - options || (options = {}); - Base.call(this, null, options); - this.filters = options.filters || {}; - this.domainfilter = !!options.domainfilter && ( - typeof this.model?.specifyModel !== 'object' - || hasHierarchyField(this.model.specifyModel) - ); - }, - url() { - return '/api/specify/' + this.model.specifyModel.name.toLowerCase() + '/'; - }, - isComplete() { - return this.length === this._totalCount; - }, - parse(resp) { - var objects; - if (resp.meta) { - this._totalCount = resp.meta.total_count; - objects = resp.objects; - } else { - console.warn("expected 'meta' in response"); - this._totalCount = resp.length; - objects = resp; - } - - return objects; - }, - async fetch(options) { - this._neverFetched = false; - - if(this._fetch) - return this._fetch; - else if(this.isComplete() || this.related?.isNew()) - return Promise.resolve(this); - - if (this.isComplete()) - console.error("fetching for already filled collection"); - - options || (options = {}); - - options.update = true; - options.remove = false; - options.silent = true; - assert(options.at == null); - - options.data = options.data || _.extend({domainfilter: this.domainfilter}, this.filters); - options.data.offset = this.length; - - _(options).has('limit') && ( options.data.limit = options.limit ); - this._fetch = Backbone.Collection.prototype.fetch.call(this, options); - return this._fetch.then(() => { this._fetch = null; return this; }); - }, - async fetchIfNotPopulated() { - return this._neverFetched && this.related?.isNew() !== true ? this.fetch() : this; - }, - getTotalCount() { - if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); - return this.fetchIfNotPopulated().then(function(_this) { - return _this._totalCount; - }); - } + }, + async fetchIfNotPopulated() { + return this._neverFetched && this.related?.isNew() !== true + ? this.fetch() + : this; + }, + getTotalCount() { + if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); + return this.fetchIfNotPopulated().then(function (_this) { + return _this._totalCount; }); - - export const ToOneCollection = LazyCollection.extend({ - __name__: "LazyToOneCollectionBase", - initialize(_models, options) { - setupToOne(this, options); - }, - async fetch() { - if (this.related.isNew()){ - console.error("can't fetch collection related to unpersisted resource"); - return this; - } - this.filters[this.field.name.toLowerCase()] = this.related.id; - return LazyCollection.prototype.fetch.apply(this, arguments); - } - }); \ No newline at end of file + }, +}); + +export const ToOneCollection = LazyCollection.extend({ + __name__: 'LazyToOneCollectionBase', + initialize(_models, options) { + setupToOne(this, options); + }, + async fetch() { + if (this.related.isNew()) { + console.error("can't fetch collection related to unpersisted resource"); + return this; + } + this.filters[this.field.name.toLowerCase()] = this.related.id; + return LazyCollection.prototype.fetch.apply(this, arguments); + }, +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index bc3b1f8b03d..519396ea1c1 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -70,7 +70,7 @@ export const getTotalResolved = ( const updatePrepBlockers = ( interactionPrep: SpecifyResource ): Promise => { - const prepUri = interactionPrep.get('preparation'); + const prepUri = interactionPrep.get('preparation') ?? ''; const prepId = idFromUrl(prepUri); return fetchResource('Preparation', prepId) .then((preparation) => { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 310dc18a52f..2a3e74caa02 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -12,7 +12,7 @@ import { SerializedResource, TableFields, } from './helperTypes'; -import { BusinessRuleMgr } from './businessRules'; +import { BusinessRuleMgr, BusinessRuleResult } from './businessRules'; /* * FEATURE: need to improve the typing to handle the following: @@ -71,6 +71,26 @@ export type SpecifyResource = { : VALUE extends RA ? string : VALUE; + + rget< + FIELD_NAME extends + | keyof SCHEMA['toOneDependent'] + | keyof SCHEMA['toOneIndependent'], + VALUE = (IR & + SCHEMA['toOneDependent'] & + SCHEMA['toOneIndependent'])[FIELD_NAME] + >( + fieldName: FIELD_NAME, + prePopulate?: boolean + ): readonly [VALUE] extends readonly [never] + ? never + : Promise< + VALUE extends AnySchema + ? SpecifyResource> + : Exclude + > & + Promise; + // Case-insensitive fetch of a -to-one resource rgetPromise< FIELD_NAME extends diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js index 4da8d39a99c..98f679abb41 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js @@ -1,607 +1,701 @@ import _ from 'underscore'; -import {Backbone} from './backbone'; +import { Backbone } from './backbone'; -import {assert} from '../Errors/assert'; -import {globalEvents} from '../../utils/ajax/specifyApi'; +import { assert } from '../Errors/assert'; +import { globalEvents } from '../../utils/ajax/specifyApi'; import { - getFieldsToNotClone, - getResourceApiUrl, - getResourceViewUrl, - resourceFromUrl + getFieldsToNotClone, + getResourceApiUrl, + getResourceViewUrl, + resourceFromUrl, } from './resource'; -import {getResourceAndField} from '../../hooks/resource'; -import {hijackBackboneAjax} from '../../utils/ajax/backboneAjax'; -import {Http} from '../../utils/ajax/definitions'; -import {removeKey} from '../../utils/utils'; +import { getResourceAndField } from '../../hooks/resource'; +import { hijackBackboneAjax } from '../../utils/ajax/backboneAjax'; +import { Http } from '../../utils/ajax/definitions'; +import { removeKey } from '../../utils/utils'; function eventHandlerForToOne(related, field) { - return function(event) { - var args = _.toArray(arguments); - - switch (event) { - case 'saverequired': - this.handleChanged(); - this.trigger.apply(this, args); - return; - case 'change:id': - this.set(field.name, related.url()); - return; - case 'changing': - this.trigger.apply(this, args); - return; - } + return function (event) { + var args = _.toArray(arguments); + + switch (event) { + case 'saverequired': + this.handleChanged(); + this.trigger.apply(this, args); + return; + case 'change:id': + this.set(field.name, related.url()); + return; + case 'changing': + this.trigger.apply(this, args); + return; + } - // pass change:field events up the tree, updating fields with dot notation - var match = /^r?(change):(.*)$/.exec(event); - if (match) { - args[0] = 'r' + match[1] + ':' + field.name.toLowerCase() + '.' + match[2]; - this.trigger.apply(this, args); - } - }; + // pass change:field events up the tree, updating fields with dot notation + var match = /^r?(change):(.*)$/.exec(event); + if (match) { + args[0] = + 'r' + match[1] + ':' + field.name.toLowerCase() + '.' + match[2]; + this.trigger.apply(this, args); } + }; +} - function eventHandlerForToMany(_related, field) { - return function(event) { - var args = _.toArray(arguments); - switch (event) { - case 'changing': - this.trigger.apply(this, args); - break; - case 'saverequired': - this.handleChanged(); - this.trigger.apply(this, args); - break; - case 'add': - case 'remove': - // annotate add and remove events with the field in which they occured - args[0] = event + ':' + field.name.toLowerCase(); - this.trigger.apply(this, args); - break; - }}; +function eventHandlerForToMany(_related, field) { + return function (event) { + var args = _.toArray(arguments); + switch (event) { + case 'changing': + this.trigger.apply(this, args); + break; + case 'saverequired': + this.handleChanged(); + this.trigger.apply(this, args); + break; + case 'add': + case 'remove': + // annotate add and remove events with the field in which they occured + args[0] = event + ':' + field.name.toLowerCase(); + this.trigger.apply(this, args); + break; } + }; +} + +export const ResourceBase = Backbone.Model.extend({ + __name__: 'ResourceBase', + populated: false, // indicates if this resource has data + _fetch: null, // stores reference to the ajax deferred while the resource is being fetched + needsSaved: false, // set when a local field is changed + _save: null, // stores reference to the ajax deferred while the resource is being saved + + constructor() { + this.specifyModel = this.constructor.specifyModel; + this.dependentResources = {}; // references to related objects referred to by field in this resource + Backbone.Model.apply(this, arguments); // TEST: check if this is necessary + }, + initialize(attributes, options) { + this.noBusinessRules = options && options.noBusinessRules; + this.noValidation = options && options.noValidation; + + // if initialized with some attributes that include a resource_uri, + // assume that represents all the fields for the resource + if (attributes && _(attributes).has('resource_uri')) this.populated = true; + + // the resource needs to be saved if any of its fields change + // unless they change because the resource is being fetched + // or updated during a save + this.on('change', function () { + if (!this._fetch && !this._save) { + this.handleChanged(); + this.trigger('saverequired'); + } + }); + globalEvents.trigger('initResource', this); + if (this.isNew()) globalEvents.trigger('newResource', this); + /* + * Business rules may set some fields on resource creation + * Those default values should not trigger unload protect + */ + this.needsSaved = false; + }, + /* + * This is encapsulated into a separate function so that can set a + * breakpoint in a single place + */ + handleChanged() { + this.needsSaved = true; + }, + async clone(cloneAll = false) { + var self = this; + + const exemptFields = getFieldsToNotClone(this.specifyModel, cloneAll).map( + (fieldName) => fieldName.toLowerCase() + ); + + var newResource = new this.constructor( + removeKey(this.attributes, 'resource_uri', 'id', ...exemptFields) + ); + + newResource.needsSaved = self.needsSaved; + + await Promise.all( + Object.entries(self.dependentResources).map( + async ([fieldName, related]) => { + if (exemptFields.includes(fieldName)) return; + var field = self.specifyModel.getField(fieldName); + switch (field.type) { + case 'many-to-one': + // many-to-one wouldn't ordinarily be dependent, but + // this is the case for paleocontext. really more like + // a one-to-one. + newResource.set(fieldName, await related?.clone(cloneAll)); + break; + case 'one-to-many': + await newResource + .rget(fieldName) + .then((newCollection) => + Promise.all( + related.models.map(async (resource) => + newCollection.add(await resource?.clone(cloneAll)) + ) + ) + ); + break; + case 'zero-to-one': + newResource.set(fieldName, await related?.clone(cloneAll)); + break; + default: + throw new Error('unhandled relationship type'); + } + } + ) + ); + return newResource; + }, + url() { + return getResourceApiUrl(this.specifyModel.name, this.id); + }, + viewUrl() { + // returns the url for viewing this resource in the UI + if (!_.isNumber(this.id)) + console.error('viewUrl called on resource w/out id', this); + return getResourceViewUrl(this.specifyModel.name, this.id); + }, + get(attribute) { + if ( + attribute.toLowerCase() === this.specifyModel.idField.name.toLowerCase() + ) + return this.id; + // case insensitive + return Backbone.Model.prototype.get.call(this, attribute.toLowerCase()); + }, + storeDependent(field, related) { + assert(field.isDependent()); + var setter = + field.type === 'one-to-many' + ? '_setDependentToMany' + : '_setDependentToOne'; + this[setter](field, related); + }, + _setDependentToOne(field, related) { + var oldRelated = this.dependentResources[field.name.toLowerCase()]; + if (!related) { + if (oldRelated) { + oldRelated.off('all', null, this); + this.trigger('saverequired'); + } + this.dependentResources[field.name.toLowerCase()] = null; + return; + } - export const ResourceBase = Backbone.Model.extend({ - __name__: "ResourceBase", - populated: false, // indicates if this resource has data - _fetch: null, // stores reference to the ajax deferred while the resource is being fetched - needsSaved: false, // set when a local field is changed - _save: null, // stores reference to the ajax deferred while the resource is being saved - - constructor() { - this.specifyModel = this.constructor.specifyModel; - this.dependentResources = {}; // references to related objects referred to by field in this resource - Backbone.Model.apply(this, arguments); // TEST: check if this is necessary - }, - initialize(attributes, options) { - this.noBusinessRules = options && options.noBusinessRules; - this.noValidation = options && options.noValidation; - - // if initialized with some attributes that include a resource_uri, - // assume that represents all the fields for the resource - if (attributes && _(attributes).has('resource_uri')) this.populated = true; - - // the resource needs to be saved if any of its fields change - // unless they change because the resource is being fetched - // or updated during a save - this.on('change', function() { - if (!this._fetch && !this._save) { - this.handleChanged(); - this.trigger('saverequired'); - } - }); - - globalEvents.trigger('initResource', this); - if(this.isNew()) - globalEvents.trigger('newResource', this); - /* - * Business rules may set some fields on resource creation - * Those default values should not trigger unload protect - */ - this.needsSaved = false; - }, + if (oldRelated && oldRelated.cid === related.cid) return; + + oldRelated && oldRelated.off('all', null, this); + + related.on('all', eventHandlerForToOne(related, field), this); + related.parent = this; // REFACTOR: this doesn't belong here + + switch (field.type) { + case 'one-to-one': + case 'many-to-one': + this.dependentResources[field.name.toLowerCase()] = related; + break; + case 'zero-to-one': + this.dependentResources[field.name.toLowerCase()] = related; + related.set(field.otherSideName, this.url()); // REFACTOR: this logic belongs somewhere else. up probably + break; + default: + throw new Error( + 'setDependentToOne: unhandled field type: ' + field.type + ); + } + }, + _setDependentToMany(field, toMany) { + var oldToMany = this.dependentResources[field.name.toLowerCase()]; + oldToMany && oldToMany.off('all', null, this); + + // cache it and set up event handlers + this.dependentResources[field.name.toLowerCase()] = toMany; + toMany.on('all', eventHandlerForToMany(toMany, field), this); + }, + set(key, value, options) { + // This may get called with "null" or "undefined" + const newValue = value ?? undefined; + const oldValue = + typeof key === 'string' + ? this.attributes[key.toLowerCase()] ?? + this.dependentResources[key.toLowerCase()] ?? + undefined + : undefined; + // Don't needlessly trigger unload protect if value didn't change + if ( + typeof key === 'string' && + typeof (oldValue ?? '') !== 'object' && + typeof (newValue ?? '') !== 'object' + ) { + if ( /* - * This is encapsulated into a separate function so that can set a - * breakpoint in a single place + * Don't trigger unload protect if: + * - value didn't change + * - value changed from string to number (back-end sends + * decimal numeric fields as string. Front-end converts + * those to numbers) + * - value was trimmed + * REFACTOR: this logic should be moved to this.parse() + * TEST: add test for "5A" case */ - handleChanged(){ - this.needsSaved = true; - }, - async clone(cloneAll = false) { - var self = this; - - const exemptFields = getFieldsToNotClone(this.specifyModel, cloneAll).map(fieldName=>fieldName.toLowerCase()); - - var newResource = new this.constructor( - removeKey( - this.attributes, - 'resource_uri', - 'id', - ...exemptFields - ) - ); - - newResource.needsSaved = self.needsSaved; - - await Promise.all(Object.entries(self.dependentResources).map(async ([fieldName,related])=>{ - if(exemptFields.includes(fieldName)) return; - var field = self.specifyModel.getField(fieldName); - switch (field.type) { - case 'many-to-one': - // many-to-one wouldn't ordinarily be dependent, but - // this is the case for paleocontext. really more like - // a one-to-one. - newResource.set(fieldName, await related?.clone(cloneAll)); - break; - case 'one-to-many': - await newResource.rget(fieldName).then((newCollection)=> - Promise.all(related.models.map(async (resource)=>newCollection.add(await resource?.clone(cloneAll)))) - ); - break; - case 'zero-to-one': - newResource.set(fieldName, await related?.clone(cloneAll)); - break; - default: - throw new Error('unhandled relationship type'); - } - })); - return newResource; - }, - url() { - return getResourceApiUrl(this.specifyModel.name, this.id); - }, - viewUrl() { - // returns the url for viewing this resource in the UI - if (!_.isNumber(this.id)) console.error("viewUrl called on resource w/out id", this); - return getResourceViewUrl(this.specifyModel.name, this.id); - }, - get(attribute) { - if(attribute.toLowerCase() === this.specifyModel.idField.name.toLowerCase()) - return this.id; - // case insensitive - return Backbone.Model.prototype.get.call(this, attribute.toLowerCase()); - }, - storeDependent(field, related) { - assert(field.isDependent()); - var setter = (field.type === 'one-to-many') ? "_setDependentToMany" : "_setDependentToOne"; - this[setter](field, related); - }, - _setDependentToOne(field, related) { - var oldRelated = this.dependentResources[field.name.toLowerCase()]; - if (!related) { - if (oldRelated) { - oldRelated.off("all", null, this); - this.trigger('saverequired'); - } - this.dependentResources[field.name.toLowerCase()] = null; - return; - } - - if (oldRelated && oldRelated.cid === related.cid) return; + oldValue?.toString() === newValue?.toString().trim() + ) + options ??= { silent: true }; + } + // make the keys case insensitive + var attrs = {}; + if (_.isObject(key) || key == null) { + // in the two argument case, so + // "key" is actually an object mapping keys to values + _(key).each(function (value, key) { + attrs[key.toLowerCase()] = value; + }); + // and the options are actually in "value" argument + options = value; + } else { + // three argument case + attrs[key.toLowerCase()] = value; + } - oldRelated && oldRelated.off("all", null, this); + // need to set the id right away if we have it because + // relationships depend on it + if ('id' in attrs) { + attrs.id = attrs.id && Number.parseInt(attrs.id); + this.id = attrs.id; + } - related.on('all', eventHandlerForToOne(related, field), this); - related.parent = this; // REFACTOR: this doesn't belong here + const adjustedAttrs = _.reduce( + attrs, + (acc, value, fieldName) => { + const [newFieldName, newValue] = this._handleField(value, fieldName); + return _.isUndefined(newValue) + ? acc + : Object.assign(acc, { [newFieldName]: newValue }); + }, + {} + ); + + return Backbone.Model.prototype.set.call(this, adjustedAttrs, options); + }, + _handleField(value, fieldName) { + if (fieldName === '_tablename') return ['_tablename', undefined]; + if (_(['id', 'resource_uri', 'recordset_info']).contains(fieldName)) + return [fieldName, value]; // special fields + + var field = this.specifyModel.getField(fieldName); + if (!field) { + console.warn( + 'setting unknown field', + fieldName, + 'on', + this.specifyModel.name, + 'value is', + value + ); + return [fieldName, value]; + } - switch (field.type) { - case 'one-to-one': - case 'many-to-one': - this.dependentResources[field.name.toLowerCase()] = related; - break; - case 'zero-to-one': - this.dependentResources[field.name.toLowerCase()] = related; - related.set(field.otherSideName, this.url()); // REFACTOR: this logic belongs somewhere else. up probably - break; - default: - throw new Error("setDependentToOne: unhandled field type: " + field.type); - } - }, - _setDependentToMany(field, toMany) { - var oldToMany = this.dependentResources[field.name.toLowerCase()]; - oldToMany && oldToMany.off("all", null, this); - - // cache it and set up event handlers - this.dependentResources[field.name.toLowerCase()] = toMany; - toMany.on('all', eventHandlerForToMany(toMany, field), this); - }, - set(key, value, options) { - // This may get called with "null" or "undefined" - const newValue = value ?? undefined; - const oldValue = typeof key === 'string' - ? this.attributes[key.toLowerCase()] ?? - this.dependentResources[key.toLowerCase()] ?? undefined - : undefined; - // Don't needlessly trigger unload protect if value didn't change - if ( - typeof key === 'string' && - typeof (oldValue??'') !== 'object' && - typeof (newValue??'') !== 'object' - ) { - if( - /* - * Don't trigger unload protect if: - * - value didn't change - * - value changed from string to number (back-end sends - * decimal numeric fields as string. Front-end converts - * those to numbers) - * - value was trimmed - * REFACTOR: this logic should be moved to this.parse() - * TEST: add test for "5A" case - */ - oldValue?.toString() === newValue?.toString().trim() - ) - options ??= {silent: true}; - } - // make the keys case insensitive - var attrs = {}; - if (_.isObject(key) || key == null) { - // in the two argument case, so - // "key" is actually an object mapping keys to values - _(key).each(function(value, key) { attrs[key.toLowerCase()] = value; }); - // and the options are actually in "value" argument - options = value; - } else { - // three argument case - attrs[key.toLowerCase()] = value; - } + fieldName = field.name.toLowerCase(); // in case field name is an alias. + + if (field.isRelationship) { + value = _.isString(value) + ? this._handleUri(value, fieldName) + : typeof value === 'number' + ? this._handleUri( + // Back-end sends SpPrincipal.scope as a number, rather than as a URL + getResourceApiUrl(field.model.name, value), + fieldName + ) + : this._handleInlineDataOrResource(value, fieldName); + } + return [fieldName, value]; + }, + _handleInlineDataOrResource(value, fieldName) { + // BUG: check type of value + const field = this.specifyModel.getField(fieldName); + const relatedModel = field.relatedModel; + + switch (field.type) { + case 'one-to-many': + // should we handle passing in an schema.Model.Collection instance here?? + var collectionOptions = { related: this, field: field.getReverse() }; + + if (field.isDependent()) { + const collection = new relatedModel.DependentCollection( + collectionOptions, + value + ); + this.storeDependent(field, collection); + } else { + console.warn( + 'got unexpected inline data for independent collection field', + { collection: this, field, value } + ); + } - // need to set the id right away if we have it because - // relationships depend on it - if ('id' in attrs) { - attrs.id = attrs.id && Number.parseInt(attrs.id); - this.id = attrs.id; - } + // because the foreign key is on the other side + this.trigger('change:' + fieldName, this); + this.trigger('change', this); + return undefined; + case 'many-to-one': + if (!value) { + // BUG: tighten up this check. + // the FK is null, or not a URI or inlined resource at any rate + field.isDependent() && this.storeDependent(field, null); + return value; + } - const adjustedAttrs = _.reduce(attrs, (acc, value, fieldName) => { - const [newFieldName, newValue] = this._handleField(value, fieldName); - return _.isUndefined(newValue) ? acc : Object.assign(acc, {[newFieldName]: newValue}); - }, {}); - - return Backbone.Model.prototype.set.call(this, adjustedAttrs, options); - }, - _handleField(value, fieldName) { - if(fieldName === '_tablename') return ['_tablename', undefined]; - if (_(['id', 'resource_uri', 'recordset_info']).contains(fieldName)) return [fieldName, value]; // special fields - - var field = this.specifyModel.getField(fieldName); - if (!field) { - console.warn("setting unknown field", fieldName, "on", - this.specifyModel.name, "value is", value); - return [fieldName, value]; - } + const toOne = + value instanceof ResourceBase + ? value + : new relatedModel.Resource(value, { parse: true }); + + field.isDependent() && this.storeDependent(field, toOne); + this.trigger('change:' + fieldName, this); + this.trigger('change', this); + return toOne.url(); // the FK as a URI + case 'zero-to-one': + // this actually a one-to-many where the related collection is only a single resource + // basically a one-to-one from the 'to' side + const oneTo = _.isArray(value) + ? value.length < 1 + ? null + : new relatedModel.Resource(_.first(value), { parse: true }) + : value || null; // in case it was undefined + + assert(oneTo == null || oneTo instanceof ResourceBase); + + field.isDependent() && this.storeDependent(field, oneTo); + // because the FK is on the other side + this.trigger('change:' + fieldName, this); + this.trigger('change', this); + return undefined; + } + console.error( + 'unhandled setting of relationship field', + fieldName, + 'on', + this, + 'value is', + value + ); + return value; + }, + _handleUri(value, fieldName) { + var field = this.specifyModel.getField(fieldName); + var oldRelated = this.dependentResources[fieldName]; + + if (field.isDependent()) { + console.warn( + 'expected inline data for dependent field', + fieldName, + 'in', + this + ); + } - fieldName = field.name.toLowerCase(); // in case field name is an alias. + if (oldRelated && field.type === 'many-to-one') { + // probably should never get here since the presence of an oldRelated + // value implies a dependent field which wouldn't be receiving a URI value + console.warn('unexpected condition'); + if (oldRelated.url() !== value) { + // the reference changed + delete this.dependentResources[fieldName]; + oldRelated.off('all', null, this); + } + } + return value; + }, + // get the value of the named field where the name may traverse related objects + // using dot notation. if the named field represents a resource or collection, + // then prePop indicates whether to return the named object or the contents of + // the field that represents it + async rget(fieldName, prePop) { + return this.getRelated(fieldName, { prePop: prePop }); + }, + // REFACTOR: remove the need for this + // Like "rget", but returns native promise + async rgetPromise(fieldName, prePop = true) { + return ( + this.getRelated(fieldName, { prePop: prePop }) + // getRelated may return either undefined or null (yuk) + .then((data) => (data === undefined ? null : data)) + ); + }, + // Duplicate definition for purposes of better typing: + async rgetCollection(fieldName) { + return this.getRelated(fieldName, { prePop: true }); + }, + async getRelated(fieldName, options) { + options || + (options = { + prePop: false, + noBusinessRules: false, + }); + var path = _(fieldName).isArray() ? fieldName : fieldName.split('.'); + + // first make sure we actually have this object. + return this.fetch() + .then(function (_this) { + return _this._rget(path, options); + }) + .then(function (value) { + // if the requested value is fetchable, and prePop is true, + // fetch the value, otherwise return the unpopulated resource + // or collection + if (options.prePop) { + if (!value) return value; // ok if the related resource doesn't exist + else if (typeof value.fetchIfNotPopulated === 'function') { + return value.fetchIfNotPopulated(); + } else if (typeof value.fetch === 'function') return value.fetch(); + } + return value; + }); + }, + async _rget(path, options) { + var fieldName = path[0].toLowerCase(); + var field = this.specifyModel.getField(fieldName); + field && (fieldName = field.name.toLowerCase()); // in case fieldName is an alias + var value = this.get(fieldName); + field || + console.warn( + 'accessing unknown field', + fieldName, + 'in', + this.specifyModel.name, + 'value is', + value + ); + + // if field represents a value, then return that if we are done, + // otherwise we can't traverse any farther... + if (!field || !field.isRelationship) { + if (path.length > 1) { + console.error('expected related field'); + return undefined; + } + return value; + } - if (field.isRelationship) { - value = _.isString(value) - ? this._handleUri(value, fieldName) - : typeof value === 'number' - ? this._handleUri( - // Back-end sends SpPrincipal.scope as a number, rather than as a URL - getResourceApiUrl(field.model.name, value), - fieldName - ) - : this._handleInlineDataOrResource(value, fieldName); - } - return [fieldName, value]; - }, - _handleInlineDataOrResource(value, fieldName) { - // BUG: check type of value - const field = this.specifyModel.getField(fieldName); - const relatedModel = field.relatedModel; - - switch (field.type) { - case 'one-to-many': - // should we handle passing in an schema.Model.Collection instance here?? - var collectionOptions = { related: this, field: field.getReverse() }; - - if (field.isDependent()) { - const collection = new relatedModel.DependentCollection(collectionOptions, value); - this.storeDependent(field, collection); - } else { - console.warn("got unexpected inline data for independent collection field",{collection:this,field,value}); - } - - // because the foreign key is on the other side - this.trigger('change:' + fieldName, this); - this.trigger('change', this); - return undefined; - case 'many-to-one': - if (!value) { // BUG: tighten up this check. - // the FK is null, or not a URI or inlined resource at any rate - field.isDependent() && this.storeDependent(field, null); - return value; - } - - const toOne = (value instanceof ResourceBase) ? value : - new relatedModel.Resource(value, {parse: true}); - - field.isDependent() && this.storeDependent(field, toOne); - this.trigger('change:' + fieldName, this); - this.trigger('change', this); - return toOne.url(); // the FK as a URI - case 'zero-to-one': - // this actually a one-to-many where the related collection is only a single resource - // basically a one-to-one from the 'to' side - const oneTo = _.isArray(value) ? - (value.length < 1 ? null : - new relatedModel.Resource(_.first(value), {parse: true})) - : (value || null); // in case it was undefined - - assert(oneTo == null || oneTo instanceof ResourceBase); - - field.isDependent() && this.storeDependent(field, oneTo); - // because the FK is on the other side - this.trigger('change:' + fieldName, this); - this.trigger('change', this); - return undefined; - } - console.error("unhandled setting of relationship field", fieldName, - "on", this, "value is", value); - return value; - }, - _handleUri(value, fieldName) { - var field = this.specifyModel.getField(fieldName); - var oldRelated = this.dependentResources[fieldName]; - - if (field.isDependent()) { - console.warn("expected inline data for dependent field", fieldName, "in", this); - } + var _this = this; + var related = field.relatedModel; + switch (field.type) { + case 'one-to-one': + case 'many-to-one': + // a foreign key field. + if (!value) return value; // no related object + + // is the related resource cached? + var toOne = this.dependentResources[fieldName]; + if (!toOne) { + _(value).isString() || console.error('expected URI, got', value); + toOne = resourceFromUrl(value, { + noBusinessRules: options.noBusinessRules, + }); + if (field.isDependent()) { + console.warn('expected dependent resource to be in cache'); + this.storeDependent(field, toOne); + } + } + // if we want a field within the related resource then recur + return path.length > 1 ? toOne.rget(_.tail(path)) : toOne; + case 'one-to-many': + if (path.length !== 1) { + return Promise.reject( + "can't traverse into a collection using dot notation" + ); + } - if (oldRelated && field.type === 'many-to-one') { - // probably should never get here since the presence of an oldRelated - // value implies a dependent field which wouldn't be receiving a URI value - console.warn("unexpected condition"); - if (oldRelated.url() !== value) { - // the reference changed - delete this.dependentResources[fieldName]; - oldRelated.off('all', null, this); - } - } - return value; - }, - // get the value of the named field where the name may traverse related objects - // using dot notation. if the named field represents a resource or collection, - // then prePop indicates whether to return the named object or the contents of - // the field that represents it - async rget(fieldName, prePop) { - return this.getRelated(fieldName, {prePop: prePop}); - }, - // REFACTOR: remove the need for this - // Like "rget", but returns native promise - async rgetPromise(fieldName, prePop = true) { - return this.getRelated(fieldName, {prePop: prePop}) - // getRelated may return either undefined or null (yuk) - .then(data=>data === undefined ? null : data); - }, - // Duplicate definition for purposes of better typing: - async rgetCollection(fieldName) { - return this.getRelated(fieldName, {prePop: true}); - }, - async getRelated(fieldName, options) { - options || (options = { - prePop: false, - noBusinessRules: false - }); - var path = _(fieldName).isArray()? fieldName : fieldName.split('.'); - - // first make sure we actually have this object. - return this.fetch().then(function(_this) { - return _this._rget(path, options); - }).then(function(value) { - // if the requested value is fetchable, and prePop is true, - // fetch the value, otherwise return the unpopulated resource - // or collection - if (options.prePop) { - if (!value) return value; // ok if the related resource doesn't exist - else if (typeof value.fetchIfNotPopulated === 'function') - return value.fetchIfNotPopulated(); - else if (typeof value.fetch === 'function') - return value.fetch(); - } - return value; - }); - }, - async _rget(path, options) { - var fieldName = path[0].toLowerCase(); - var field = this.specifyModel.getField(fieldName); - field && (fieldName = field.name.toLowerCase()); // in case fieldName is an alias - var value = this.get(fieldName); - field || console.warn("accessing unknown field", fieldName, "in", - this.specifyModel.name, "value is", - value); - - // if field represents a value, then return that if we are done, - // otherwise we can't traverse any farther... - if (!field || !field.isRelationship) { - if (path.length > 1) { - console.error("expected related field"); - return undefined; - } - return value; - } + // is the collection cached? + var toMany = this.dependentResources[fieldName]; + if (!toMany) { + var collectionOptions = { field: field.getReverse(), related: this }; + + if (!field.isDependent()) { + return new related.ToOneCollection(collectionOptions); + } + + if (this.isNew()) { + toMany = new related.DependentCollection(collectionOptions, []); + this.storeDependent(field, toMany); + return toMany; + } else { + console.warn('expected dependent resource to be in cache'); + var tempCollection = new related.ToOneCollection(collectionOptions); + return tempCollection + .fetch({ limit: 0 }) + .then(function () { + return new related.DependentCollection( + collectionOptions, + tempCollection.models + ); + }) + .then(function (toMany) { + _this.storeDependent(field, toMany); + }); + } + } + case 'zero-to-one': + // this is like a one-to-many where the many cannot be more than one + // i.e. the current resource is the target of a FK + + // is it already cached? + if (!_.isUndefined(this.dependentResources[fieldName])) { + value = this.dependentResources[fieldName]; + if (value == null) return null; + // recur if we need to traverse more + return path.length === 1 ? value : value.rget(_.tail(path)); + } - var _this = this; - var related = field.relatedModel; - switch (field.type) { - case 'one-to-one': - case 'many-to-one': - // a foreign key field. - if (!value) return value; // no related object - - // is the related resource cached? - var toOne = this.dependentResources[fieldName]; - if (!toOne) { - _(value).isString() || console.error("expected URI, got", value); - toOne = resourceFromUrl(value, {noBusinessRules: options.noBusinessRules}); - if (field.isDependent()) { - console.warn("expected dependent resource to be in cache"); - this.storeDependent(field, toOne); - } - } - // if we want a field within the related resource then recur - return (path.length > 1) ? toOne.rget(_.tail(path)) : toOne; - case 'one-to-many': - if (path.length !== 1) { - return Promise.reject("can't traverse into a collection using dot notation"); - } - - // is the collection cached? - var toMany = this.dependentResources[fieldName]; - if (!toMany) { - var collectionOptions = { field: field.getReverse(), related: this }; - - if (!field.isDependent()) { - return new related.ToOneCollection(collectionOptions); - } - - if (this.isNew()) { - toMany = new related.DependentCollection(collectionOptions, []); - this.storeDependent(field, toMany); - return toMany; - } else { - console.warn("expected dependent resource to be in cache"); - var tempCollection = new related.ToOneCollection(collectionOptions); - return tempCollection.fetch({ limit: 0 }).then(function() { - return new related.DependentCollection(collectionOptions, tempCollection.models); - }).then(function (toMany) { _this.storeDependent(field, toMany); }); - } - } - case 'zero-to-one': - // this is like a one-to-many where the many cannot be more than one - // i.e. the current resource is the target of a FK - - // is it already cached? - if (!_.isUndefined(this.dependentResources[fieldName])) { - value = this.dependentResources[fieldName]; - if (value == null) return null; - // recur if we need to traverse more - return (path.length === 1) ? value : value.rget(_.tail(path)); - } - - // if this resource is not yet persisted, the related object can't point to it yet - if (this.isNew()) return undefined; // TEST: this seems iffy - - var collection = new related.ToOneCollection({ field: field.getReverse(), related: this, limit: 1 }); - - // fetch the collection and pretend like it is a single resource - return collection.fetchIfNotPopulated().then(function() { - var value = collection.isEmpty() ? null : collection.first(); - if (field.isDependent()) { - console.warn("expect dependent resource to be in cache"); - _this.storeDependent(field, value); - } - if (value == null) return null; - return (path.length === 1) ? value : value.rget(_.tail(path)); - }); - default: - console.error("unhandled relationship type: " + field.type); - return Promise.reject('unhandled relationship type'); - } - }, - save({onSaveConflict:handleSaveConflict,errorOnAlreadySaving=true}={}) { - var resource = this; - if (resource._save) { - if(errorOnAlreadySaving) - throw new Error('resource is already being saved'); - else return resource._save; + // if this resource is not yet persisted, the related object can't point to it yet + if (this.isNew()) return undefined; // TEST: this seems iffy + + var collection = new related.ToOneCollection({ + field: field.getReverse(), + related: this, + limit: 1, + }); + + // fetch the collection and pretend like it is a single resource + return collection.fetchIfNotPopulated().then(function () { + var value = collection.isEmpty() ? null : collection.first(); + if (field.isDependent()) { + console.warn('expect dependent resource to be in cache'); + _this.storeDependent(field, value); + } + if (value == null) return null; + return path.length === 1 ? value : value.rget(_.tail(path)); + }); + default: + console.error('unhandled relationship type: ' + field.type); + return Promise.reject('unhandled relationship type'); + } + }, + save({ + onSaveConflict: handleSaveConflict, + errorOnAlreadySaving = true, + } = {}) { + var resource = this; + if (resource._save) { + if (errorOnAlreadySaving) + throw new Error('resource is already being saved'); + else return resource._save; + } + var didNeedSaved = resource.needsSaved; + resource.needsSaved = false; + // BUG: should do this for dependent resources too + + let errorHandled = false; + const save = () => + Backbone.Model.prototype.save + .apply(resource, []) + .then(() => resource.trigger('saved')); + resource._save = + typeof handleSaveConflict === 'function' + ? hijackBackboneAjax( + [Http.OK, Http.CONFLICT, Http.CREATED], + save, + (status) => { + if (status === Http.CONFLICT) { + handleSaveConflict(); + errorHandled = true; + } } - var didNeedSaved = resource.needsSaved; - resource.needsSaved = false; - // BUG: should do this for dependent resources too - - let errorHandled = false; - const save = ()=>Backbone.Model.prototype.save.apply(resource, []) - .then(()=>resource.trigger('saved')); - resource._save = - typeof handleSaveConflict === 'function' - ? hijackBackboneAjax([Http.OK, Http.CONFLICT, Http.CREATED], save, (status) =>{ - if(status === Http.CONFLICT) { - handleSaveConflict() - errorHandled = true; - } - }) - : save(); - - resource._save.catch(function(error) { - resource._save = null; - resource.needsSaved = didNeedSaved; - didNeedSaved && resource.trigger('saverequired'); - if(typeof handleSaveConflict === 'function' && errorHandled) - Object.defineProperty(error, 'handledBy', { - value: handleSaveConflict, - }); - throw error; - }).then(function() { - resource._save = null; - }); - - return resource._save.then(()=>resource); - }, - toJSON() { - var self = this; - var json = Backbone.Model.prototype.toJSON.apply(self, arguments); - - _.each(self.dependentResources, function(related, fieldName) { - var field = self.specifyModel.getField(fieldName); - if (field.type === 'zero-to-one') { - json[fieldName] = related ? [related.toJSON()] : []; - } else { - json[fieldName] = related ? related.toJSON() : null; - } - }); - return json; - }, - // Caches a reference to Promise so as not to start fetching twice - async fetch(options) { - if( - // if already populated - this.populated || - // or if can't be populated by fetching - this.isNew() - ) - return this; - else if (this._fetch) return this._fetch; - else - return this._fetch = Backbone.Model.prototype.fetch.call(this, options).then(()=>{ - this._fetch = null; - // BUG: consider doing this.needsSaved=false here - return this; - }); - }, - parse(_resp) { - // Since we are putting in data, the resource in now populated - this.populated = true; - return Backbone.Model.prototype.parse.apply(this, arguments); - }, - async sync(method, resource, options) { - options = options || {}; - if(method === 'delete') - // When deleting we don't send any data so put the version in a header - options.headers = {'If-Match': resource.get('version')}; - return Backbone.sync(method, resource, options); - }, - async getResourceAndField(fieldName) { - return getResourceAndField(this, fieldName); - }, - async placeInSameHierarchy(other) { - var self = this; - var myPath = self.specifyModel.getScopingPath(); - var otherPath = other.specifyModel.getScopingPath(); - if (!myPath || !otherPath) return undefined; - if (myPath.length > otherPath.length) return undefined; - var diff = _(otherPath).rest(myPath.length - 1).reverse(); - return other.rget(diff.join('.')).then(function(common) { - if(common === undefined) return undefined; - self.set(_(diff).last(), common.url()); - return common; - }); - }, - getDependentResource(fieldName){ - return this.dependentResources[fieldName.toLowerCase()]; - } + ) + : save(); + + resource._save + .catch(function (error) { + resource._save = null; + resource.needsSaved = didNeedSaved; + didNeedSaved && resource.trigger('saverequired'); + if (typeof handleSaveConflict === 'function' && errorHandled) + Object.defineProperty(error, 'handledBy', { + value: handleSaveConflict, + }); + throw error; + }) + .then(function () { + resource._save = null; + }); + + return resource._save.then(() => resource); + }, + toJSON() { + var self = this; + var json = Backbone.Model.prototype.toJSON.apply(self, arguments); + + _.each(self.dependentResources, function (related, fieldName) { + var field = self.specifyModel.getField(fieldName); + if (field.type === 'zero-to-one') { + json[fieldName] = related ? [related.toJSON()] : []; + } else { + json[fieldName] = related ? related.toJSON() : null; + } + }); + return json; + }, + // Caches a reference to Promise so as not to start fetching twice + async fetch(options) { + if ( + // if already populated + this.populated || + // or if can't be populated by fetching + this.isNew() + ) + return this; + else if (this._fetch) return this._fetch; + else + return (this._fetch = Backbone.Model.prototype.fetch + .call(this, options) + .then(() => { + this._fetch = null; + // BUG: consider doing this.needsSaved=false here + return this; + })); + }, + parse(_resp) { + // Since we are putting in data, the resource in now populated + this.populated = true; + return Backbone.Model.prototype.parse.apply(this, arguments); + }, + async sync(method, resource, options) { + options = options || {}; + if (method === 'delete') + // When deleting we don't send any data so put the version in a header + options.headers = { 'If-Match': resource.get('version') }; + return Backbone.sync(method, resource, options); + }, + async getResourceAndField(fieldName) { + return getResourceAndField(this, fieldName); + }, + async placeInSameHierarchy(other) { + var self = this; + var myPath = self.specifyModel.getScopingPath(); + var otherPath = other.specifyModel.getScopingPath(); + if (!myPath || !otherPath) return undefined; + if (myPath.length > otherPath.length) return undefined; + var diff = _(otherPath) + .rest(myPath.length - 1) + .reverse(); + return other.rget(diff.join('.')).then(function (common) { + if (common === undefined) return undefined; + self.set(_(diff).last(), common.url()); + return common; }); + }, + getDependentResource(fieldName) { + return this.dependentResources[fieldName.toLowerCase()]; + }, +}); export function promiseToXhr(promise) { promise.done = function (fn) { From ad3f75d9eda8f4f8bd2b601b17c1c95f56f268f3 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Feb 2023 10:38:53 -0600 Subject: [PATCH 05/50] Fix dockerize errors --- .../lib/components/DataModel/collectionApi.js | 210 +++++------------- .../lib/components/DataModel/resourceApi.js | 8 - 2 files changed, 53 insertions(+), 165 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js index 0042864c9cd..e52c11f1cdc 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js @@ -1,12 +1,13 @@ import _ from 'underscore'; -import { Backbone } from './backbone'; + import { assert } from '../Errors/assert'; +import { Backbone } from './backbone'; import { hasHierarchyField } from './schema'; -var Base = Backbone.Collection.extend({ +const Base = Backbone.Collection.extend({ __name__: 'CollectionBase', - getTotalCount() { - return Promise.resolve(this.length); + async getTotalCount() { + return this.length; }, }); @@ -42,8 +43,10 @@ export const DependentCollection = Base.extend({ this.on( 'add remove', function () { - // Warning: changing a collection record does not trigger a - // change event in the parent (though it probably should) + /* + * Warning: changing a collection record does not trigger a + * change event in the parent (though it probably should) + */ this.trigger('saverequired'); }, this @@ -51,16 +54,18 @@ export const DependentCollection = Base.extend({ setupToOne(this, options); - // If the id of the related resource changes, we go through and update - // all the objects that point to it with the new pointer. - // This is to support having collections of objects attached to - // newly created resources that don't have ids yet. When the - // resource is saved, the related objects can have their foreign keys - // set correctly. + /* + * If the id of the related resource changes, we go through and update + * all the objects that point to it with the new pointer. + * This is to support having collections of objects attached to + * newly created resources that don't have ids yet. When the + * resource is saved, the related objects can have their foreign keys + * set correctly. + */ this.related.on( 'change:id', function () { - var relatedUrl = this.related.url(); + const relatedUrl = this.related.url(); _.chain(this.models) .compact() .invoke('set', this.field.name, relatedUrl); @@ -80,22 +85,22 @@ export const LazyCollection = Base.extend({ __name__: 'LazyCollectionBase', _neverFetched: true, constructor(options) { - options || (options = {}); + options ||= {}; Base.call(this, null, options); this.filters = options.filters || {}; this.domainfilter = - !!options.domainfilter && + Boolean(options.domainfilter) && (typeof this.model?.specifyModel !== 'object' || hasHierarchyField(this.model.specifyModel)); }, url() { - return '/api/specify/' + this.model.specifyModel.name.toLowerCase() + '/'; + return `/api/specify/${this.model.specifyModel.name.toLowerCase()}/`; }, isComplete() { return this.length === this._totalCount; }, parse(resp) { - var objects; + let objects; if (resp.meta) { this._totalCount = resp.meta.total_count; objects = resp.objects; @@ -110,17 +115,13 @@ export const LazyCollection = Base.extend({ async fetch(options) { this._neverFetched = false; - const Base = Backbone.Collection.extend({ - __name__: 'CollectionBase', - async getTotalCount() { - return this.length; - }, - }); + if (this._fetch) return this._fetch; + else if (this.isComplete() || this.related?.isNew()) return this; if (this.isComplete()) console.error('fetching for already filled collection'); - options || (options = {}); + options ||= {}; options.update = true; options.remove = false; @@ -132,140 +133,35 @@ export const LazyCollection = Base.extend({ _.extend({ domainfilter: this.domainfilter }, this.filters); options.data.offset = this.length; - export const DependentCollection = Base.extend({ - __name__: 'DependentCollectionBase', - constructor(options, models = []) { - assert(_.isArray(models)); - Base.call(this, models, options); - }, - initialize(_models, options) { - this.on( - 'add remove', - function () { - /* - * Warning: changing a collection record does not trigger a - * change event in the parent (though it probably should) - */ - this.trigger('saverequired'); - }, - this - ); - - setupToOne(this, options); - - /* - * If the id of the related resource changes, we go through and update - * all the objects that point to it with the new pointer. - * This is to support having collections of objects attached to - * newly created resources that don't have ids yet. When the - * resource is saved, the related objects can have their foreign keys - * set correctly. - */ - this.related.on( - 'change:id', - function () { - const relatedUrl = this.related.url(); - _.chain(this.models) - .compact() - .invoke('set', this.field.name, relatedUrl); - }, - this - ); - }, - isComplete() { - return true; - }, - fetch: fakeFetch, - sync: notSupported, - create: notSupported, - }); - - export const LazyCollection = Base.extend({ - __name__: 'LazyCollectionBase', - _neverFetched: true, - constructor(options) { - options ||= {}; - Base.call(this, null, options); - this.filters = options.filters || {}; - this.domainfilter = - Boolean(options.domainfilter) && - (typeof this.model?.specifyModel !== 'object' || - hasHierarchyField(this.model.specifyModel)); - }, - url() { - return `/api/specify/${this.model.specifyModel.name.toLowerCase()}/`; - }, - isComplete() { - return this.length === this._totalCount; - }, - parse(resp) { - let objects; - if (resp.meta) { - this._totalCount = resp.meta.total_count; - objects = resp.objects; - } else { - console.warn("expected 'meta' in response"); - this._totalCount = resp.length; - objects = resp; - } - - return objects; - }, - async fetch(options) { - this._neverFetched = false; - - if (this._fetch) return this._fetch; - else if (this.isComplete() || this.related?.isNew()) return this; - - if (this.isComplete()) - console.error('fetching for already filled collection'); - - options ||= {}; - - options.update = true; - options.remove = false; - options.silent = true; - assert(options.at == null); - - options.data = - options.data || - _.extend({ domainfilter: this.domainfilter }, this.filters); - options.data.offset = this.length; - - _(options).has('limit') && (options.data.limit = options.limit); - this._fetch = Backbone.Collection.prototype.fetch.call(this, options); - return this._fetch.then(() => { - this._fetch = null; - return this; - }); - }, - async fetchIfNotPopulated() { - return this._neverFetched && this.related?.isNew() !== true - ? this.fetch() - : this; - }, - getTotalCount() { - if (_.isNumber(this._totalCount)) - return Promise.resolve(this._totalCount); - return this.fetchIfNotPopulated().then((_this) => _this._totalCount); - }, + _(options).has('limit') && (options.data.limit = options.limit); + this._fetch = Backbone.Collection.prototype.fetch.call(this, options); + return this._fetch.then(() => { + this._fetch = null; + return this; }); + }, + async fetchIfNotPopulated() { + return this._neverFetched && this.related?.isNew() !== true + ? this.fetch() + : this; + }, + getTotalCount() { + if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); + return this.fetchIfNotPopulated().then((_this) => _this._totalCount); + }, +}); - export const ToOneCollection = LazyCollection.extend({ - __name__: 'LazyToOneCollectionBase', - initialize(_models, options) { - setupToOne(this, options); - }, - async fetch() { - if (this.related.isNew()) { - console.error( - "can't fetch collection related to unpersisted resource" - ); - return this; - } - this.filters[this.field.name.toLowerCase()] = this.related.id; - return Reflect.apply(LazyCollection.prototype.fetch, this, arguments); - }, - }); +export const ToOneCollection = LazyCollection.extend({ + __name__: 'LazyToOneCollectionBase', + initialize(_models, options) { + setupToOne(this, options); + }, + async fetch() { + if (this.related.isNew()) { + console.error("can't fetch collection related to unpersisted resource"); + return this; + } + this.filters[this.field.name.toLowerCase()] = this.related.id; + return Reflect.apply(LazyCollection.prototype.fetch, this, arguments); }, }); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js index e6ea7f5bb5d..82107d11706 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.js @@ -1,26 +1,18 @@ import _ from 'underscore'; import { Backbone } from './backbone'; -import { assert } from '../Errors/assert'; -import { globalEvents } from '../../utils/ajax/specifyApi'; - import { hijackBackboneAjax } from '../../utils/ajax/backboneAjax'; import { Http } from '../../utils/ajax/definitions'; import { globalEvents } from '../../utils/ajax/specifyApi'; import { removeKey } from '../../utils/utils'; import { assert } from '../Errors/assert'; import { softFail } from '../Errors/Crash'; -import { Backbone } from './backbone'; import { getFieldsToNotClone, getResourceApiUrl, getResourceViewUrl, resourceFromUrl, } from './resource'; -import { getResourceAndField } from '../../hooks/resource'; -import { hijackBackboneAjax } from '../../utils/ajax/backboneAjax'; -import { Http } from '../../utils/ajax/definitions'; -import { removeKey } from '../../utils/utils'; function eventHandlerForToOne(related, field) { return function (event) { From 047b17acae3cf3366d7e3f3a251a524d3e2d555f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 22 Feb 2023 13:24:25 -0600 Subject: [PATCH 06/50] Add missing UniquenessRules Fixes #2260 Adds the following Uniqueness Rules: Of the form: Table.field -> uniqueIn CollectingEvent.uniqueidentifier -> discipline CollectionObject.uniqueidentifier -> collection DisposalAgent.role/agent -> disposal Extractor.agent -> dnasequence Fundingagent.agentt -> collectingtrip Locality.uniqueidentifier -> discipline LocalityCitation.referencework -> locality PcrPerson.agent -> dnasequence Specifyuser.name -> (DATABASE) --- .../components/DataModel/uniquness_rules.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index c74ecd1af11..325cbf06d56 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -73,10 +73,18 @@ "discipline" ] }, + "CollectingEvent": { + "uniqueidentifier" : [ + "discipline" + ] + }, "CollectionObject":{ "catalognumber":[ "collection" ], + "uniqueidentifier" : [ + "collection" + ], "guid":[ "institution" ] @@ -96,11 +104,39 @@ "division" ] }, + "DisposalAgent" : { + "role":[ + { + "field":"disposal", + "otherfields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"disposal", + "otherfields":[ + "role" + ] + } + ] + }, "Division":{ "name":[ "institution" ] }, + "Extractor" : { + "agent" : [ + "dnasequence" + ] + }, + "FundingAgent" : { + "agent" : [ + "collectingtrip" + ] + }, "Gift":{ "giftnumber":[ "discipline" @@ -157,6 +193,21 @@ } ] }, + "Locality" : { + "uniqueidentifier" : [ + "discipline" + ] + }, + "LocalityCitation" : { + "referencework" : [ + "locality" + ] + }, + "PcrPerson" : { + "agent" : [ + "dnasequence" + ] + }, "Permit":{ "permitnumber":[ null @@ -186,5 +237,10 @@ "spappresource":[ null ] + }, + "SpecifyUser" : { + "name" : [ + null + ] } } \ No newline at end of file From c810ea8eb10d9abd2ec3439b8508174d0dd68382 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 24 Feb 2023 15:10:29 -0600 Subject: [PATCH 07/50] Remove _ dependency from businessRule files --- .../lib/components/DataModel/businessRules.ts | 47 ++++++++----------- .../DataModel/interactionBusinessRules.ts | 26 ++++------ .../frontend/js_src/lib/utils/functools.ts | 6 +++ 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 3ee93db21c7..d10bcaaa5b2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -5,7 +5,6 @@ import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; import { flippedPromise } from '../../utils/promise'; import { isTreeResource } from '../InitialContext/treeRanks'; import { initializeTreeRecord, treeBusinessRules } from './treeBusinessRules'; -import _ from 'underscore'; import { Collection } from './specifyModel'; import { SaveBlockers } from './saveBlockers'; import { formatConjunction } from '../Atoms/Internationalization'; @@ -13,6 +12,7 @@ import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; import { globalEvents } from '../../utils/ajax/specifyApi'; +import { f } from '../../utils/functools'; var enabled: boolean = true; @@ -147,11 +147,9 @@ export class BusinessRuleMgr { }); Promise.all(results).then((results) => { - _.chain(results) - .pluck('localDuplicates') - .compact() - .flatten() - .each((duplicate: SpecifyResource) => { + f.pluck(results, 'localDuplicates') + .flat() + .forEach((duplicate) => { const event = duplicate.cid + ':' + fieldName; if (_this.watchers[event]) { return; @@ -171,7 +169,7 @@ export class BusinessRuleMgr { : { key: 'br-uniqueness-' + fieldName, valid: false, - reason: formatConjunction(_(invalids).pluck('reason')), + reason: formatConjunction(f.pluck(invalids, 'reason')), }; }); } @@ -236,11 +234,10 @@ export class BusinessRuleMgr { ? (this.resource.specifyModel.getField(toOneField) as Relationship) : undefined; - const allNullOrUndefinedToOnes = _.reduce( - fieldIds, - (result, value, index) => { - return result && fieldIsToOne[index] - ? _.isNull(fieldIds[index]) + const allNullOrUndefinedToOnes = fieldIds.reduce( + (previous, current, index) => { + return previous && fieldIsToOne[index] + ? fieldIds[index] === null : false; }, true @@ -277,21 +274,17 @@ export class BusinessRuleMgr { } else return fieldValue === otherValue; }; - return _.reduce( - fieldValues, - (result, fieldValue, index) => { - return ( - result && - hasSameValue( - fieldValue, - fieldNames![index], - fieldIsToOne[index], - fieldIds[index] - ) - ); - }, - true - ); + return fieldValues.reduce((previous, current, index) => { + return ( + previous && + hasSameValue( + current, + fieldNames![index], + fieldIsToOne[index], + fieldIds[index] + ) + ); + }, true); }; if (toOneField != null) { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index 519396ea1c1..d13037dd98a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -1,4 +1,3 @@ -import _ from 'underscore'; import { formsText } from '../../localization/forms'; import { getPrepAvailability } from '../../utils/ajax/specifyApi'; import { f } from '../../utils/functools'; @@ -30,17 +29,16 @@ export const getTotalLoaned = ( export const updateLoanPrep = ( loanReturnPrep: SpecifyResource, - collection: Collection + collection: Collection ) => { if ( collection != undefined && collection.related?.specifyModel.name == 'LoanPreparation' ) { - const sums = _.reduce( - collection.models, - (memo: { returned: number; resolved: number }, lrp) => { - memo.returned += lrp.get('quantityReturned'); - memo.resolved += lrp.get('quantityResolved'); + const sums = collection.models.reduce( + (memo: { returned: number; resolved: number }, loanReturnPrep) => { + memo.returned += loanReturnPrep.get('quantityReturned'); + memo.resolved += loanReturnPrep.get('quantityResolved'); return memo; }, { returned: 0, resolved: 0 } @@ -55,15 +53,11 @@ export const getTotalResolved = ( loanReturnPrep: SpecifyResource ) => { return loanReturnPrep.collection - ? _.reduce( - loanReturnPrep.collection.models, - (sum, loanPrep) => { - return loanPrep.cid != loanReturnPrep.cid - ? sum + loanPrep.get('quantityResolved') - : sum; - }, - 0 - ) + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + return loanPrep.cid != loanReturnPrep.cid + ? sum + loanPrep.get('quantityResolved') + : sum; + }, 0) : loanReturnPrep.get('quantityResolved'); }; diff --git a/specifyweb/frontend/js_src/lib/utils/functools.ts b/specifyweb/frontend/js_src/lib/utils/functools.ts index 5cc230412f9..c517b3469a1 100644 --- a/specifyweb/frontend/js_src/lib/utils/functools.ts +++ b/specifyweb/frontend/js_src/lib/utils/functools.ts @@ -114,6 +114,12 @@ export const f = { * Useful when mapping over a list of functions */ call: (callback: () => RETURN): RETURN => callback(), + /** + * Given an array of objects, return an array containing the values + * of a given key from each object in the array + */ + pluck: >>(array: T, key: string): RA => + array.map((obj) => obj[key]), /** * Wrap a pure function that does not need any arguments in this * call to remember and return its return value. From 1d097050e0003a526344f75861ccfea2ce7fc588 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 24 Feb 2023 16:01:13 -0600 Subject: [PATCH 08/50] Remove unnecessary _this statements Also, fix a bug where uniqueness rules containing more than one field were not registering the `otherField` --- .../lib/components/DataModel/businessRules.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index d10bcaaa5b2..f780f172166 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -93,13 +93,11 @@ export class BusinessRuleMgr { treeBusinessRules(this.resource as SpecifyResource, fieldName) ); - const _this = this; - Promise.all(checks) .then((results) => { return ( - thisCheck === _this.fieldChangePromises[fieldName] && - _this.processCheckFieldResults(fieldName, results) + thisCheck === this.fieldChangePromises[fieldName] && + this.processCheckFieldResults(fieldName, results) ); }) .then(() => { @@ -130,33 +128,32 @@ export class BusinessRuleMgr { } private async checkUnique(fieldName: string): Promise { - const _this = this; var toOneFields: RA = (this.rules?.uniqueIn && this.rules.uniqueIn[fieldName]) ?? []; const results = toOneFields.map((uniqueRule) => { var field = uniqueRule; - var fieldNames: string[] | null = []; + var fieldNames: string[] | null = [fieldName]; if (uniqueRule === null) { fieldNames = null; } else if (typeof uniqueRule != 'string') { fieldNames = fieldNames.concat(uniqueRule.otherfields); field = uniqueRule.field; - } else fieldNames = [fieldName]; - return _this.uniqueIn(field, fieldNames); + } + return this.uniqueIn(field, fieldNames); }); Promise.all(results).then((results) => { f.pluck(results, 'localDuplicates') .flat() - .forEach((duplicate) => { + .forEach((duplicate: SpecifyResource) => { const event = duplicate.cid + ':' + fieldName; - if (_this.watchers[event]) { + if (this.watchers[event]) { return; } - _this.watchers[event] = () => + this.watchers[event] = () => duplicate.on('change remove', () => { - _this.checkField(fieldName); + this.checkField(fieldName); }); }); }); From 7017a3c6531126921f8d2553e6ab67d39fbf264b Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 24 Feb 2023 16:17:10 -0600 Subject: [PATCH 09/50] Refactor out pluck function --- .../js_src/lib/components/DataModel/businessRules.ts | 7 +++++-- specifyweb/frontend/js_src/lib/utils/functools.ts | 6 ------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index f780f172166..a74762df7a4 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -144,7 +144,8 @@ export class BusinessRuleMgr { }); Promise.all(results).then((results) => { - f.pluck(results, 'localDuplicates') + results + .map((result) => result['localDuplicates']) .flat() .forEach((duplicate: SpecifyResource) => { const event = duplicate.cid + ':' + fieldName; @@ -166,7 +167,9 @@ export class BusinessRuleMgr { : { key: 'br-uniqueness-' + fieldName, valid: false, - reason: formatConjunction(f.pluck(invalids, 'reason')), + reason: formatConjunction( + invalids.map((invalid) => invalid['reason']) + ), }; }); } diff --git a/specifyweb/frontend/js_src/lib/utils/functools.ts b/specifyweb/frontend/js_src/lib/utils/functools.ts index c517b3469a1..5cc230412f9 100644 --- a/specifyweb/frontend/js_src/lib/utils/functools.ts +++ b/specifyweb/frontend/js_src/lib/utils/functools.ts @@ -114,12 +114,6 @@ export const f = { * Useful when mapping over a list of functions */ call: (callback: () => RETURN): RETURN => callback(), - /** - * Given an array of objects, return an array containing the values - * of a given key from each object in the array - */ - pluck: >>(array: T, key: string): RA => - array.map((obj) => obj[key]), /** * Wrap a pure function that does not need any arguments in this * call to remember and return its return value. From 5611147b16feb4b2a9589d5d32add85441e773c4 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 24 Feb 2023 16:41:03 -0600 Subject: [PATCH 10/50] Remove unnecessary return statements in arrow functions --- .../lib/components/DataModel/businessRules.ts | 79 ++++++++----------- .../DataModel/interactionBusinessRules.ts | 23 +++--- 2 files changed, 44 insertions(+), 58 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index a74762df7a4..39bc510bd15 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -145,23 +145,18 @@ export class BusinessRuleMgr { Promise.all(results).then((results) => { results - .map((result) => result['localDuplicates']) + .map((result) => result['localDuplicates' as keyof BusinessRuleResult]) .flat() .forEach((duplicate: SpecifyResource) => { const event = duplicate.cid + ':' + fieldName; - if (this.watchers[event]) { - return; + if (!this.watchers[event]) { + this.watchers[event] = () => + duplicate.on('change remove', () => this.checkField(fieldName)); } - this.watchers[event] = () => - duplicate.on('change remove', () => { - this.checkField(fieldName); - }); }); }); return Promise.all(results).then((results) => { - const invalids = results.filter((result) => { - return !result.valid; - }); + const invalids = results.filter((result) => !result.valid); return invalids.length < 1 ? { key: 'br-uniqueness-' + fieldName, valid: true } : { @@ -207,17 +202,15 @@ export class BusinessRuleMgr { } fieldNames = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; - const fieldValues = fieldNames.map((value) => { - return this.resource.get(value); - }); + const fieldValues = fieldNames.map((value) => this.resource.get(value)); - const fieldInfo = fieldNames.map((field) => { - return this.resource.specifyModel.getField(field); - }); + const fieldInfo = fieldNames.map((field) => + this.resource.specifyModel.getField(field) + ); - const fieldIsToOne = fieldInfo.map((field) => { - return field?.type === 'many-to-one'; - }); + const fieldIsToOne = fieldInfo.map( + (field) => field?.type === 'many-to-one' + ); const fieldIds = fieldValues.map((value, index) => { if (fieldIsToOne[index] != null) { @@ -235,11 +228,8 @@ export class BusinessRuleMgr { : undefined; const allNullOrUndefinedToOnes = fieldIds.reduce( - (previous, current, index) => { - return previous && fieldIsToOne[index] - ? fieldIds[index] === null - : false; - }, + (previous, current, index) => + previous && fieldIsToOne[index] ? fieldIds[index] === null : false, true ); @@ -274,17 +264,17 @@ export class BusinessRuleMgr { } else return fieldValue === otherValue; }; - return fieldValues.reduce((previous, current, index) => { - return ( + return fieldValues.reduce( + (previous, current, index) => previous && hasSameValue( current, fieldNames![index], fieldIsToOne[index], fieldIds[index] - ) - ); - }, true); + ), + true + ); }; if (toOneField != null) { @@ -295,14 +285,14 @@ export class BusinessRuleMgr { this.resource.collection.related?.specifyModel; var localCollection = hasLocalCollection - ? this.resource.collection.models.filter((resource) => { - return resource !== undefined; - }) + ? this.resource.collection.models.filter( + (resource) => resource !== undefined + ) : []; - var duplicates = localCollection.filter((resource) => { - return hasSameValues(resource); - }); + var duplicates = localCollection.filter((resource) => + hasSameValues(resource) + ); if (duplicates.length > 0) { invalidResponse.localDuplicates = duplicates; @@ -327,17 +317,14 @@ export class BusinessRuleMgr { var inDatabase = others.chain().compact(); inDatabase = hasLocalCollection ? inDatabase - .filter((other: SpecifyResource) => { - return !this.resource.collection.get(other.id); - }) + .filter( + (other: SpecifyResource) => + !this.resource.collection.get(other.id) + ) .value() : inDatabase.value(); - if ( - inDatabase.some((other) => { - return hasSameValues(other); - }) - ) { + if (inDatabase.some((other) => hasSameValues(other))) { return invalidResponse; } else { return { valid: true }; @@ -355,9 +342,9 @@ export class BusinessRuleMgr { }); return others.fetch().then(() => { if ( - others.models.some((other: SpecifyResource) => { - return hasSameValues(other); - }) + others.models.some((other: SpecifyResource) => + hasSameValues(other) + ) ) { return invalidResponse; } else { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index d13037dd98a..6c2f5d6497d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -27,10 +27,7 @@ export const getTotalLoaned = ( : undefined; }; -export const updateLoanPrep = ( - loanReturnPrep: SpecifyResource, - collection: Collection -) => { +export const updateLoanPrep = (collection: Collection) => { if ( collection != undefined && collection.related?.specifyModel.name == 'LoanPreparation' @@ -53,11 +50,13 @@ export const getTotalResolved = ( loanReturnPrep: SpecifyResource ) => { return loanReturnPrep.collection - ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { - return loanPrep.cid != loanReturnPrep.cid - ? sum + loanPrep.get('quantityResolved') - : sum; - }, 0) + ? loanReturnPrep.collection.models.reduce( + (sum, loanPrep) => + loanPrep.cid != loanReturnPrep.cid + ? sum + loanPrep.get('quantityResolved') + : sum, + 0 + ) : loanReturnPrep.get('quantityResolved'); }; @@ -67,9 +66,9 @@ const updatePrepBlockers = ( const prepUri = interactionPrep.get('preparation') ?? ''; const prepId = idFromUrl(prepUri); return fetchResource('Preparation', prepId) - .then((preparation) => { - return preparation.countAmt >= interactionPrep.get('quantity'); - }) + .then( + (preparation) => preparation.countAmt >= interactionPrep.get('quantity') + ) .then((isValid) => { if (!isValid) { if (interactionPrep.saveBlockers?.blockers) From ee19febc2a5216862825c7b904f3e2f6f4ff3697 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 27 Feb 2023 11:08:51 -0600 Subject: [PATCH 11/50] Add type for Uniqueness Rules --- .../components/DataModel/businessRuleDefs.ts | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 1a6430e9bef..9758a8af71e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -1,5 +1,5 @@ import { f } from '../../utils/functools'; -import { overwriteReadOnly } from '../../utils/types'; +import { overwriteReadOnly, RA } from '../../utils/types'; import { AnySchema, TableFields } from './helperTypes'; import { checkPrepAvailability, @@ -29,7 +29,7 @@ export type BusinessRuleDefs = { resource: SpecifyResource, collection: Collection ) => void; - readonly uniqueIn?: { [key: string]: string }; + readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { [FIELDNAME in TableFields as Lowercase]?: ( @@ -38,6 +38,19 @@ export type BusinessRuleDefs = { }; }; +export const uniqueRules: UniquenessRules = uniquenessRules; + +type UniquenessRules = { + [TABLE in keyof Tables]?: UniquenessRule; +}; + +export type UniquenessRule = { + [FIELDNAME in TableFields as Lowercase]?: + | string[] + | null[] + | RA<{ field: string; otherfields: string[] }>; +}; + type MappedBusinessRuleDefs = { [TABLE in keyof Tables]?: BusinessRuleDefs; }; @@ -45,7 +58,7 @@ type MappedBusinessRuleDefs = { function assignUniquenessRules( mappedRules: MappedBusinessRuleDefs ): MappedBusinessRuleDefs { - Object.keys(uniquenessRules).forEach((table) => { + Object.keys(uniqueRules).forEach((table) => { if (mappedRules[table] == undefined) overwriteReadOnly(mappedRules, table, {}); @@ -136,7 +149,7 @@ export const businessRuleDefs = f.store( determination: SpecifyResource ): Promise => { return determination - .rget('taxon', true) + .rgetPromise('taxon', true) .then((taxon: SpecifyResource | null) => taxon == null ? { @@ -149,11 +162,12 @@ export const businessRuleDefs = f.store( return taxon.get('acceptedTaxon') == null ? { valid: true, - action: () => { - determination.set('preferredTaxon', taxon); - }, + action: () => + determination.set('preferredTaxon', taxon), } - : taxon.rget('acceptedTaxon', true).then(recur); + : taxon + .rgetPromise('acceptedTaxon', true) + .then((accepted) => recur(accepted)); })(taxon) ); }, @@ -203,7 +217,7 @@ export const businessRuleDefs = f.store( loanReturnPrep: SpecifyResource, collection: Collection ): void => { - updateLoanPrep(loanReturnPrep, collection); + updateLoanPrep(collection); }, customInit: ( resource: SpecifyResource @@ -240,7 +254,7 @@ export const businessRuleDefs = f.store( } interactionCache().previousReturned[Number(loanReturnPrep.cid)] = loanReturnPrep.get('quantityReturned'); - updateLoanPrep(loanReturnPrep, loanReturnPrep.collection); + updateLoanPrep(loanReturnPrep.collection); } }, quantityresolved: ( @@ -270,7 +284,7 @@ export const businessRuleDefs = f.store( } interactionCache().previousResolved[Number(loanReturnPrep.cid)] = loanReturnPrep.get('quantityResolved'); - updateLoanPrep(loanReturnPrep, loanReturnPrep.collection); + updateLoanPrep(loanReturnPrep.collection); } }, }, From 01a72759d9b4fcb2a4cc4b67d22d9a292815e907 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 27 Feb 2023 12:40:55 -0600 Subject: [PATCH 12/50] Fix most typescript errors --- .../components/DataModel/businessRuleDefs.ts | 8 +- .../lib/components/DataModel/businessRules.ts | 156 +++++++----------- .../DataModel/interactionBusinessRules.ts | 42 +++-- .../lib/components/DataModel/legacyTypes.ts | 22 +-- .../frontend/js_src/lib/utils/promise.ts | 2 +- 5 files changed, 88 insertions(+), 142 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 9758a8af71e..57429d945a5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -59,10 +59,14 @@ function assignUniquenessRules( mappedRules: MappedBusinessRuleDefs ): MappedBusinessRuleDefs { Object.keys(uniqueRules).forEach((table) => { - if (mappedRules[table] == undefined) + if (mappedRules[table as keyof Tables] == undefined) overwriteReadOnly(mappedRules, table, {}); - overwriteReadOnly(mappedRules[table]!, 'uniqueIn', uniquenessRules[table]); + overwriteReadOnly( + mappedRules[table as keyof Tables]!, + 'uniqueIn', + uniquenessRules[table as keyof Tables] + ); }); return mappedRules; } diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 39bc510bd15..cab52da43ea 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -1,8 +1,8 @@ import { overwriteReadOnly, RA } from '../../utils/types'; -import { AnySchema, AnyTree } from './helperTypes'; +import { AnySchema, AnyTree, TableFields } from './helperTypes'; import { SpecifyResource } from './legacyTypes'; import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; -import { flippedPromise } from '../../utils/promise'; +import { flippedPromise, ResolvablePromise } from '../../utils/promise'; import { isTreeResource } from '../InitialContext/treeRanks'; import { initializeTreeRecord, treeBusinessRules } from './treeBusinessRules'; import { Collection } from './specifyModel'; @@ -12,7 +12,6 @@ import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; import { globalEvents } from '../../utils/ajax/specifyApi'; -import { f } from '../../utils/functools'; var enabled: boolean = true; @@ -32,7 +31,7 @@ export class BusinessRuleMgr { public pendingPromises: Promise = Promise.resolve(null); private fieldChangePromises: { - [key: string]: Promise | Promise; + [key: string]: ResolvablePromise; } = {}; private watchers: { [key: string]: () => void } = {}; @@ -41,7 +40,9 @@ export class BusinessRuleMgr { this.rules = businessRuleDefs()[this.resource.specifyModel.name]; } - private addPromise(promise: Promise): void { + private addPromise( + promise: Promise + ): void { this.pendingPromises = Promise.allSettled([ this.pendingPromises, promise, @@ -76,7 +77,7 @@ export class BusinessRuleMgr { public checkField(fieldName: string) { fieldName = fieldName.toLocaleLowerCase(); - const thisCheck: Promise = flippedPromise(); + const thisCheck: ResolvablePromise = flippedPromise(); this.addPromise(thisCheck); this.fieldChangePromises[fieldName] !== undefined && @@ -95,13 +96,12 @@ export class BusinessRuleMgr { Promise.all(checks) .then((results) => { - return ( - thisCheck === this.fieldChangePromises[fieldName] && - this.processCheckFieldResults(fieldName, results) - ); + return thisCheck === this.fieldChangePromises[fieldName] + ? this.processCheckFieldResults(fieldName, results) + : undefined; }) .then(() => { - resolveFlippedPromise(thisCheck, 'finished'); + thisCheck.resolve('finished'); }); } @@ -128,10 +128,13 @@ export class BusinessRuleMgr { } private async checkUnique(fieldName: string): Promise { - var toOneFields: RA = - (this.rules?.uniqueIn && this.rules.uniqueIn[fieldName]) ?? []; + var toOneFields = + this.rules?.uniqueIn !== undefined + ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? + [] + : []; - const results = toOneFields.map((uniqueRule) => { + const results: RA = toOneFields.map((uniqueRule) => { var field = uniqueRule; var fieldNames: string[] | null = [fieldName]; if (uniqueRule === null) { @@ -140,13 +143,17 @@ export class BusinessRuleMgr { fieldNames = fieldNames.concat(uniqueRule.otherfields); field = uniqueRule.field; } - return this.uniqueIn(field, fieldNames); + return this.uniqueIn(field as string, fieldNames); }); Promise.all(results).then((results) => { results - .map((result) => result['localDuplicates' as keyof BusinessRuleResult]) + .map( + (result: BusinessRuleResult) => + result['localDuplicates' as keyof BusinessRuleResult] + ) .flat() + .filter((result) => result !== undefined) .forEach((duplicate: SpecifyResource) => { const event = duplicate.cid + ':' + fieldName; if (!this.watchers[event]) { @@ -163,7 +170,10 @@ export class BusinessRuleMgr { key: 'br-uniqueness-' + fieldName, valid: false, reason: formatConjunction( - invalids.map((invalid) => invalid['reason']) + invalids.map( + (invalid) => + invalid['reason' as keyof BusinessRuleResult] as string + ) ), }; }); @@ -191,7 +201,7 @@ export class BusinessRuleMgr { } private uniqueIn( - toOneField: string | undefined, + toOneField: string | undefined | null, fieldNames: RA | string | null ): Promise { if (fieldNames === null) { @@ -214,16 +224,16 @@ export class BusinessRuleMgr { const fieldIds = fieldValues.map((value, index) => { if (fieldIsToOne[index] != null) { - if (value == null || typeof value == 'undefined') { + if (value == null || value === undefined) { return null; } else { - return typeof value === 'string' ? idFromUrl(value) : value.id; + return idFromUrl(value); } } else return undefined; }); const toOneFieldInfo = - toOneField !== undefined + toOneField !== null && toOneField !== undefined ? (this.resource.specifyModel.getField(toOneField) as Relationship) : undefined; @@ -238,7 +248,8 @@ export class BusinessRuleMgr { } = { valid: false, reason: - toOneFieldInfo !== undefined && fieldInfo !== undefined + toOneFieldInfo !== undefined && + !fieldInfo.some((field) => field === undefined) ? this.getUniqueInvalidReason(toOneFieldInfo, fieldInfo) : '', }; @@ -248,47 +259,29 @@ export class BusinessRuleMgr { const hasSameValues = (other: SpecifyResource): boolean => { const hasSameValue = ( fieldValue: string | number | null, - fieldName: string, - fieldIsToOne: boolean, - fieldId: string + fieldName: string ): boolean => { if (other.id != null && other.id === this.resource.id) return false; if (other.cid === this.resource.cid) return false; const otherValue = other.get(fieldName); - if ( - fieldIsToOne && - typeof otherValue != 'undefined' && - typeof otherValue !== 'string' - ) { - return Number.parseInt(otherValue?.id) === Number.parseInt(fieldId); - } else return fieldValue === otherValue; + + return fieldValue === otherValue; }; return fieldValues.reduce( (previous, current, index) => - previous && - hasSameValue( - current, - fieldNames![index], - fieldIsToOne[index], - fieldIds[index] - ), + previous && hasSameValue(current, fieldNames![index]), true ); }; if (toOneField != null) { - const hasLocalCollection = - this.resource.collection && - this.resource.collection.related && - toOneFieldInfo?.relatedModel === - this.resource.collection.related?.specifyModel; - - var localCollection = hasLocalCollection - ? this.resource.collection.models.filter( - (resource) => resource !== undefined - ) - : []; + var localCollection = + this.resource.collection?.models !== undefined + ? this.resource.collection.models.filter( + (resource) => resource !== undefined + ) + : []; var duplicates = localCollection.filter((resource) => hasSameValues(resource) @@ -300,7 +293,7 @@ export class BusinessRuleMgr { } return this.resource - .rget(toOneField) + .rgetPromise(toOneField) .then((related: SpecifyResource) => { if (!related) return Promise.resolve({ valid: true }); var filters = {}; @@ -313,16 +306,10 @@ export class BusinessRuleMgr { filters: filters, }); - return others.fetch().then(() => { - var inDatabase = others.chain().compact(); - inDatabase = hasLocalCollection - ? inDatabase - .filter( - (other: SpecifyResource) => - !this.resource.collection.get(other.id) - ) - .value() - : inDatabase.value(); + return others.fetch().then((fetchedCollection) => { + var inDatabase = fetchedCollection.models.filter( + (otherResource) => otherResource !== undefined + ); if (inDatabase.some((other) => hasSameValues(other))) { return invalidResponse; @@ -340,9 +327,9 @@ export class BusinessRuleMgr { const others = new this.resource.specifyModel.LazyCollection({ filters: filters, }); - return others.fetch().then(() => { + return others.fetch().then((fetchedCollection) => { if ( - others.models.some((other: SpecifyResource) => + fetchedCollection.models.some((other: SpecifyResource) => hasSameValues(other) ) ) { @@ -359,43 +346,19 @@ export class BusinessRuleMgr { fieldName: string | undefined, args: RA ): Promise { - if (this.rules === undefined) { + if (this.rules === undefined || fieldName === undefined) { return Promise.resolve(undefined); } - var rule = this.rules[ruleName]; - const isValid = - (rule && typeof rule == 'function') || - (fieldName && - rule && - rule[fieldName] && - typeof rule[fieldName] == 'function'); - - if (!isValid) return Promise.resolve(undefined); - - if (fieldName !== undefined) { - rule = rule[fieldName]; - if (!rule) { - return Promise.resolve({ - key: 'invalidRule', - valid: false, - reason: 'no rule: ' + ruleName + ' for: ' + fieldName, - }); - } - } - if (rule == undefined) { - return Promise.resolve({ - key: 'invalidRule', - valid: false, - reason: 'no rule: ' + ruleName + ' for: ' + fieldName, - }); + const rule = this.rules?.[ruleName]?.[fieldName]; + + if (typeof rule !== 'function') { + return Promise.resolve(undefined); } return Promise.resolve(rule.apply(this, args)); } } -export function attachBusinessRules( - resource: SpecifyResource -): void { +function attachBusinessRules(resource: SpecifyResource): void { const businessRuleManager = new BusinessRuleMgr(resource); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); overwriteReadOnly(resource, 'businessRuleMgr', businessRuleManager); @@ -411,10 +374,3 @@ export type BusinessRuleResult = { } | { readonly valid: false; readonly reason: string } ); - -const resolveFlippedPromise = ( - promise: Promise, - ...args: RA -): void => { - globalThis.setTimeout(() => promise.resolve(...args), 0); -}; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index 6c2f5d6497d..9de36531650 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -27,7 +27,9 @@ export const getTotalLoaned = ( : undefined; }; -export const updateLoanPrep = (collection: Collection) => { +export const updateLoanPrep = ( + collection: Collection +) => { if ( collection != undefined && collection.related?.specifyModel.name == 'LoanPreparation' @@ -40,7 +42,8 @@ export const updateLoanPrep = (collection: Collection) => { }, { returned: 0, resolved: 0 } ); - const loanPrep: SpecifyResource = collection.related; + const loanPrep: SpecifyResource = + collection.related as SpecifyResource; loanPrep.set('quantityReturned', sums.returned); loanPrep.set('quantityResolved', sums.resolved); } @@ -65,22 +68,25 @@ const updatePrepBlockers = ( ): Promise => { const prepUri = interactionPrep.get('preparation') ?? ''; const prepId = idFromUrl(prepUri); - return fetchResource('Preparation', prepId) - .then( - (preparation) => preparation.countAmt >= interactionPrep.get('quantity') - ) - .then((isValid) => { - if (!isValid) { - if (interactionPrep.saveBlockers?.blockers) - interactionPrep.saveBlockers?.add( - 'parseError-quantity', - 'quantity', - formsText.invalidValue() - ); - } else { - interactionPrep.saveBlockers?.remove('parseError-quantity'); - } - }); + return prepId === undefined + ? Promise.resolve() + : fetchResource('Preparation', prepId) + .then( + (preparation) => + preparation.countAmt >= interactionPrep.get('quantity') + ) + .then((isValid) => { + if (!isValid) { + if (interactionPrep.saveBlockers?.blockers) + interactionPrep.saveBlockers?.add( + 'parseError-quantity', + 'quantity', + formsText.invalidValue() + ); + } else { + interactionPrep.saveBlockers?.remove('parseError-quantity'); + } + }); }; export const checkPrepAvailability = ( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 04cb74d6ce0..3bae18428af 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -10,7 +10,7 @@ import type { SerializedResource, TableFields, } from './helperTypes'; -import { BusinessRuleMgr, BusinessRuleResult } from './businessRules'; +import { BusinessRuleMgr } from './businessRules'; import type { SaveBlockers } from './saveBlockers'; import type { Collection, SpecifyModel } from './specifyModel'; @@ -71,26 +71,6 @@ export type SpecifyResource = { : VALUE extends RA ? string : VALUE; - - rget< - FIELD_NAME extends - | keyof SCHEMA['toOneDependent'] - | keyof SCHEMA['toOneIndependent'], - VALUE = (IR & - SCHEMA['toOneDependent'] & - SCHEMA['toOneIndependent'])[FIELD_NAME] - >( - fieldName: FIELD_NAME, - prePopulate?: boolean - ): readonly [VALUE] extends readonly [never] - ? never - : Promise< - VALUE extends AnySchema - ? SpecifyResource> - : Exclude - > & - Promise; - // Case-insensitive fetch of a -to-one resource rgetPromise< FIELD_NAME extends diff --git a/specifyweb/frontend/js_src/lib/utils/promise.ts b/specifyweb/frontend/js_src/lib/utils/promise.ts index 29ad6cb5cc3..aa4560677e1 100644 --- a/specifyweb/frontend/js_src/lib/utils/promise.ts +++ b/specifyweb/frontend/js_src/lib/utils/promise.ts @@ -1,4 +1,4 @@ -type ResolvablePromise = Promise & { +export type ResolvablePromise = Promise & { // eslint-disable-next-line functional/prefer-readonly-type resolve: T extends undefined ? (value?: T) => void : (value: T) => void; // eslint-disable-next-line functional/prefer-readonly-type From b08cf8e980d9c2110c8620fdc9da0b38f779728f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 27 Feb 2023 15:53:04 -0600 Subject: [PATCH 13/50] Add type to BusinessRuleResult --- .../lib/components/DataModel/businessRules.ts | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index cab52da43ea..7a753254747 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -134,27 +134,27 @@ export class BusinessRuleMgr { [] : []; - const results: RA = toOneFields.map((uniqueRule) => { - var field = uniqueRule; - var fieldNames: string[] | null = [fieldName]; - if (uniqueRule === null) { - fieldNames = null; - } else if (typeof uniqueRule != 'string') { - fieldNames = fieldNames.concat(uniqueRule.otherfields); - field = uniqueRule.field; + const results: RA> = toOneFields.map( + (uniqueRule) => { + var field = uniqueRule; + var fieldNames: string[] | null = [fieldName]; + if (uniqueRule === null) { + fieldNames = null; + } else if (typeof uniqueRule != 'string') { + fieldNames = fieldNames.concat(uniqueRule.otherfields); + field = uniqueRule.field; + } + return this.uniqueIn(field as string, fieldNames); } - return this.uniqueIn(field as string, fieldNames); - }); + ); Promise.all(results).then((results) => { results - .map( - (result: BusinessRuleResult) => - result['localDuplicates' as keyof BusinessRuleResult] - ) + .map((result: BusinessRuleResult) => result['localDuplicates']) .flat() .filter((result) => result !== undefined) - .forEach((duplicate: SpecifyResource) => { + .forEach((duplicate: SpecifyResource | undefined) => { + if (duplicate === undefined) return; const event = duplicate.cid + ':' + fieldName; if (!this.watchers[event]) { this.watchers[event] = () => @@ -203,7 +203,7 @@ export class BusinessRuleMgr { private uniqueIn( toOneField: string | undefined | null, fieldNames: RA | string | null - ): Promise { + ): Promise> { if (fieldNames === null) { return Promise.resolve({ valid: false, @@ -214,8 +214,8 @@ export class BusinessRuleMgr { const fieldValues = fieldNames.map((value) => this.resource.get(value)); - const fieldInfo = fieldNames.map((field) => - this.resource.specifyModel.getField(field) + const fieldInfo = fieldNames.map( + (field) => this.resource.specifyModel.getField(field)! ); const fieldIsToOne = fieldInfo.map( @@ -243,9 +243,7 @@ export class BusinessRuleMgr { true ); - const invalidResponse: BusinessRuleResult & { - localDuplicates?: RA>; - } = { + const invalidResponse: BusinessRuleResult = { valid: false, reason: toOneFieldInfo !== undefined && @@ -288,7 +286,7 @@ export class BusinessRuleMgr { ); if (duplicates.length > 0) { - invalidResponse.localDuplicates = duplicates; + overwriteReadOnly(invalidResponse, 'localDuplicates', duplicates); return Promise.resolve(invalidResponse); } @@ -365,8 +363,9 @@ function attachBusinessRules(resource: SpecifyResource): void { businessRuleManager.setUpManager(); } -export type BusinessRuleResult = { +export type BusinessRuleResult = { readonly key?: string; + readonly localDuplicates?: RA>; } & ( | { readonly valid: true; From be8197faf620e68a743e93e0b655cdd51b91f687 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Feb 2023 09:15:25 -0600 Subject: [PATCH 14/50] Add count support for DNASequence geneSequence Fixes #436 --- .../components/DataModel/businessRuleDefs.ts | 45 +++++++++++++++++++ .../components/DataModel/schemaOverrides.ts | 8 ++++ 2 files changed, 53 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 57429d945a5..041e74221bc 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -16,6 +16,7 @@ import { BorrowMaterial, CollectionObject, Determination, + DNASequence, GiftPreparation, LoanPreparation, LoanReturnPreparation, @@ -202,6 +203,50 @@ export const businessRuleDefs = f.store( }, }, }, + DNASequence: { + fieldChecks: { + genesequence: (dnaSequence: SpecifyResource): void => { + const current = dnaSequence.get('geneSequence'); + if (current === null) return; + var countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; + for (var i = 0; i < current.length; i++) { + const char = current.at(i)?.toLowerCase().trim(); + if (char !== '') { + switch (char) { + case 'a': + countObj['a'] += 1; + break; + case 't': + countObj['t'] += 1; + break; + case 'g': + countObj['g'] += 1; + break; + case 'c': + countObj['c'] += 1; + break; + default: + countObj['ambiguous'] += 1; + } + } + } + + dnaSequence.set('compA', countObj['a']); + dnaSequence.set('compT', countObj['t']); + dnaSequence.set('compG', countObj['g']); + dnaSequence.set('compC', countObj['c']); + dnaSequence.set('ambiguousResidues', countObj['ambiguous']); + dnaSequence.set( + 'totalResidues', + countObj['a'] + + countObj['t'] + + countObj['g'] + + countObj['c'] + + countObj['ambiguous'] + ); + }, + }, + }, GiftPreparation: { fieldChecks: { quantity: (iprep: SpecifyResource): void => { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts index 9872f16e56b..fe6801e2470 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts @@ -110,6 +110,14 @@ const globalFieldOverrides: { Attachment: { tableID: 'optional', }, + DNASequence: { + totalResidues: 'readOnly', + compA: 'readOnly', + compG: 'readOnly', + compC: 'readOnly', + compT: 'readOnly', + ambiguousResidues: 'readOnly', + }, Taxon: { parent: 'required', isAccepted: 'readOnly', From 9713e12fd155938ab48a4a8105db49918018e753 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Feb 2023 09:52:01 -0600 Subject: [PATCH 15/50] Add UniquenessRule for TaxonTreeDef and TaxonTreeDefItem --- .../lib/components/DataModel/uniquness_rules.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index 325cbf06d56..5cc807224c3 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -242,5 +242,16 @@ "name" : [ null ] + }, + "TaxonTreeDef" : { + "name": ["discipline"] + }, + "TaxonTreeDefItem" : { + "name" : [ + "treeDef" + ], + "title" : [ + "treeDef" + ] } } \ No newline at end of file From 070bce559029ad102b433218d0307f2844b0a54c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 1 Mar 2023 16:53:18 -0600 Subject: [PATCH 16/50] Fix typescript errors in interactionBusinessRules.ts --- .../DataModel/interactionBusinessRules.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index 9de36531650..30ca1f2e8e7 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -36,8 +36,10 @@ export const updateLoanPrep = ( ) { const sums = collection.models.reduce( (memo: { returned: number; resolved: number }, loanReturnPrep) => { - memo.returned += loanReturnPrep.get('quantityReturned'); - memo.resolved += loanReturnPrep.get('quantityResolved'); + const returned = loanReturnPrep.get('quantityReturned'); + const resolved = loanReturnPrep.get('quantityResolved'); + memo.returned += typeof returned === 'number' ? returned : 0; + memo.resolved += typeof resolved === 'number' ? resolved : 0; return memo; }, { returned: 0, resolved: 0 } @@ -53,13 +55,12 @@ export const getTotalResolved = ( loanReturnPrep: SpecifyResource ) => { return loanReturnPrep.collection - ? loanReturnPrep.collection.models.reduce( - (sum, loanPrep) => - loanPrep.cid != loanReturnPrep.cid - ? sum + loanPrep.get('quantityResolved') - : sum, - 0 - ) + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + const resolved = loanPrep.get('quantityResolved'); + return loanPrep.cid != loanReturnPrep.cid + ? sum + (typeof resolved === 'number' ? resolved : 0) + : sum; + }, 0) : loanReturnPrep.get('quantityResolved'); }; @@ -71,10 +72,13 @@ const updatePrepBlockers = ( return prepId === undefined ? Promise.resolve() : fetchResource('Preparation', prepId) - .then( - (preparation) => - preparation.countAmt >= interactionPrep.get('quantity') - ) + .then((preparation) => { + const prepQuanity = interactionPrep.get('quantity'); + return typeof preparation.countAmt === 'number' && + typeof prepQuanity === 'number' + ? preparation.countAmt >= prepQuanity + : false; + }) .then((isValid) => { if (!isValid) { if (interactionPrep.saveBlockers?.blockers) @@ -97,7 +101,7 @@ export const checkPrepAvailability = ( interactionPrep.get('preparation') != undefined ) { const prepUri = interactionPrep.get('preparation'); - const prepId = idFromUrl(prepUri); + const prepId = typeof prepUri === 'string' ? idFromUrl(prepUri) : undefined; updatePrepBlockers(interactionPrep); const interactionId = interactionPrep.isNew() ? undefined @@ -105,16 +109,18 @@ export const checkPrepAvailability = ( const interactionModelName = interactionPrep.isNew() ? undefined : interactionPrep.specifyModel.name; - - getPrepAvailability(prepId!, interactionId, interactionModelName!).then( - (available) => { - if ( - typeof available != 'undefined' && - Number(available[0]) < interactionPrep.get('quantity') - ) { - interactionPrep.set('quantity', Number(available[0])); + if (prepId !== undefined) + getPrepAvailability(prepId, interactionId, interactionModelName!).then( + (available) => { + const quantity = interactionPrep.get('quantity'); + if ( + typeof available != 'undefined' && + typeof quantity === 'number' && + Number(available[0]) < quantity + ) { + interactionPrep.set('quantity', Number(available[0])); + } } - } - ); + ); } }; From 7c4bb25966d269e4bc79a321763987a553477997 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 1 Mar 2023 23:41:13 -0600 Subject: [PATCH 17/50] Address most code comments --- .../components/DataModel/businessRuleDefs.ts | 113 ++++++++++-------- .../lib/components/DataModel/businessRules.ts | 112 +++++++++-------- .../lib/components/DataModel/legacyTypes.ts | 10 +- .../lib/components/DataModel/specifyModel.ts | 2 - .../components/DataModel/uniquness_rules.json | 24 ++-- 5 files changed, 145 insertions(+), 116 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 041e74221bc..60d0738a1bc 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -23,7 +23,7 @@ import { Tables, Taxon, } from './types'; -import * as uniquenessRules from './uniquness_rules.json'; +import uniquenessRules from './uniquness_rules.json'; export type BusinessRuleDefs = { readonly onRemoved?: ( @@ -33,7 +33,7 @@ export type BusinessRuleDefs = { readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { - [FIELDNAME in TableFields as Lowercase]?: ( + [FIELD_NAME in TableFields as Lowercase]?: ( resource: SpecifyResource ) => Promise | void; }; @@ -46,10 +46,10 @@ type UniquenessRules = { }; export type UniquenessRule = { - [FIELDNAME in TableFields as Lowercase]?: - | string[] + [FIELD_NAME in TableFields as Lowercase]?: + | RA | null[] - | RA<{ field: string; otherfields: string[] }>; + | RA<{ field: string; otherFields: string[] }>; }; type MappedBusinessRuleDefs = { @@ -60,7 +60,7 @@ function assignUniquenessRules( mappedRules: MappedBusinessRuleDefs ): MappedBusinessRuleDefs { Object.keys(uniqueRules).forEach((table) => { - if (mappedRules[table as keyof Tables] == undefined) + if (mappedRules[table as keyof Tables] === undefined) overwriteReadOnly(mappedRules, table, {}); overwriteReadOnly( @@ -84,7 +84,11 @@ export const businessRuleDefs = f.store( const resolved = borrowMaterial.get('quantityResolved'); const quantity = borrowMaterial.get('quantity'); var newVal: number | undefined = undefined; - if (quantity && returned && returned > quantity) { + if ( + typeof quantity === 'number' && + typeof returned === 'number' && + returned > quantity + ) { newVal = quantity; } if (returned && resolved && returned > resolved) { @@ -99,7 +103,7 @@ export const businessRuleDefs = f.store( const resolved = borrowMaterial.get('quantityResolved'); const quantity = borrowMaterial.get('quantity'); const returned = borrowMaterial.get('quantityReturned'); - var newVal: number | undefined = undefined; + let newVal: number | undefined = undefined; if (resolved && quantity && resolved > quantity) { newVal = quantity; } @@ -107,7 +111,8 @@ export const businessRuleDefs = f.store( newVal = returned; } - newVal && borrowMaterial.set('quantityResolved', newVal); + if (typeof newVal === 'number') + borrowMaterial.set('quantityResolved', newVal); }, }, }, @@ -116,7 +121,7 @@ export const businessRuleDefs = f.store( customInit: function ( collectionObject: SpecifyResource ): void { - var ceField = + const ceField = collectionObject.specifyModel.getField('collectingEvent'); if ( ceField?.isDependent() && @@ -136,7 +141,7 @@ export const businessRuleDefs = f.store( const setCurrent = () => { determinaton.set('isCurrent', true); if (determinaton.collection != null) { - determinaton.collection.each( + determinaton.collection.models.map( (other: SpecifyResource) => { if (other.cid !== determinaton.cid) { other.set('isCurrent', false); @@ -155,26 +160,30 @@ export const businessRuleDefs = f.store( ): Promise => { return determination .rgetPromise('taxon', true) - .then((taxon: SpecifyResource | null) => - taxon == null + .then((taxon: SpecifyResource | null) => { + const getLastAccepted = ( + taxon: SpecifyResource + ): Promise> => { + return taxon + .rgetPromise('acceptedTaxon', true) + .then((accepted) => + accepted === null ? taxon : getLastAccepted(accepted) + ); + }; + return taxon === null ? { valid: true, - action: () => { - determination.set('preferredTaxon', null); - }, + action: () => determination.set('preferredTaxon', null), } - : (function recur(taxon): BusinessRuleResult { - return taxon.get('acceptedTaxon') == null - ? { - valid: true, - action: () => - determination.set('preferredTaxon', taxon), - } - : taxon - .rgetPromise('acceptedTaxon', true) - .then((accepted) => recur(accepted)); - })(taxon) - ); + : { + valid: true, + action: async () => + determination.set( + 'preferredTaxon', + await getLastAccepted(taxon) + ), + }; + }); }, iscurrent: ( determination: SpecifyResource @@ -183,7 +192,7 @@ export const businessRuleDefs = f.store( determination.get('isCurrent') && determination.collection != null ) { - determination.collection.each( + determination.collection.models.map( (other: SpecifyResource) => { if (other.cid !== determination.cid) { other.set('isCurrent', false); @@ -193,7 +202,7 @@ export const businessRuleDefs = f.store( } if ( determination.collection != null && - !determination.collection.any( + !determination.collection.models.some( (c: SpecifyResource) => c.get('isCurrent') ) ) { @@ -208,8 +217,8 @@ export const businessRuleDefs = f.store( genesequence: (dnaSequence: SpecifyResource): void => { const current = dnaSequence.get('geneSequence'); if (current === null) return; - var countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; - for (var i = 0; i < current.length; i++) { + const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; + for (let i = 0; i < current.length; i++) { const char = current.at(i)?.toLowerCase().trim(); if (char !== '') { switch (char) { @@ -230,7 +239,6 @@ export const businessRuleDefs = f.store( } } } - dnaSequence.set('compA', countObj['a']); dnaSequence.set('compT', countObj['t']); dnaSequence.set('compG', countObj['g']); @@ -265,9 +273,8 @@ export const businessRuleDefs = f.store( onRemoved: ( loanReturnPrep: SpecifyResource, collection: Collection - ): void => { - updateLoanPrep(collection); - }, + ): void => updateLoanPrep(collection), + customInit: ( resource: SpecifyResource ): void => { @@ -280,18 +287,22 @@ export const businessRuleDefs = f.store( quantityreturned: ( loanReturnPrep: SpecifyResource ): void => { - var returned = loanReturnPrep.get('quantityReturned'); - var previousReturned = interactionCache().previousReturned[ + const returned = loanReturnPrep.get('quantityReturned'); + const previousReturned = interactionCache().previousReturned[ Number(loanReturnPrep.cid) ] ? interactionCache().previousReturned[Number(loanReturnPrep.cid)] : 0; if (returned !== null && returned != previousReturned) { - var delta = returned - previousReturned; - var resolved = loanReturnPrep.get('quantityResolved'); - var totalLoaned = getTotalLoaned(loanReturnPrep); - var totalResolved = getTotalResolved(loanReturnPrep); - var max = totalLoaned - totalResolved; + const delta = returned - previousReturned; + let resolved = loanReturnPrep.get('quantityResolved'); + const totalLoaned = getTotalLoaned(loanReturnPrep); + const totalResolved = getTotalResolved(loanReturnPrep); + const max = + typeof totalLoaned === 'number' && + typeof totalResolved === 'number' + ? totalLoaned - totalResolved + : 0; if (resolved !== null && delta + resolved > max) { loanReturnPrep.set('quantityReturned', previousReturned); } else { @@ -309,17 +320,21 @@ export const businessRuleDefs = f.store( quantityresolved: ( loanReturnPrep: SpecifyResource ): void => { - var resolved = loanReturnPrep.get('quantityResolved'); - var previousResolved = interactionCache().previousResolved[ + const resolved = loanReturnPrep.get('quantityResolved'); + const previousResolved = interactionCache().previousResolved[ Number(loanReturnPrep.cid) ] ? interactionCache().previousResolved[Number(loanReturnPrep.cid)] : 0; if (resolved != previousResolved) { - var returned = loanReturnPrep.get('quantityReturned'); - var totalLoaned = getTotalLoaned(loanReturnPrep); - var totalResolved = getTotalResolved(loanReturnPrep); - var max = totalLoaned - totalResolved; + const returned = loanReturnPrep.get('quantityReturned'); + const totalLoaned = getTotalLoaned(loanReturnPrep); + const totalResolved = getTotalResolved(loanReturnPrep); + const max = + typeof totalLoaned === 'number' && + typeof totalResolved === 'number' + ? totalLoaned - totalResolved + : 0; if (resolved !== null && returned !== null) { if (resolved > max) { loanReturnPrep.set('quantityResolved', previousResolved); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 7a753254747..da369b23520 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -1,5 +1,5 @@ -import { overwriteReadOnly, RA } from '../../utils/types'; -import { AnySchema, AnyTree, TableFields } from './helperTypes'; +import { IR, overwriteReadOnly, RA } from '../../utils/types'; +import { AnySchema, AnyTree, CommonFields } from './helperTypes'; import { SpecifyResource } from './legacyTypes'; import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; import { flippedPromise, ResolvablePromise } from '../../utils/promise'; @@ -13,7 +13,7 @@ import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; import { globalEvents } from '../../utils/ajax/specifyApi'; -var enabled: boolean = true; +let enabled: boolean = true; globalEvents.on('initResource', (resource) => { enabled && !resource.noBusinessRules @@ -25,7 +25,7 @@ export function enableBusinessRules(e: boolean) { return (enabled = e); } -export class BusinessRuleMgr { +export class BusinessRuleManager { private readonly resource: SpecifyResource; private readonly rules: BusinessRuleDefs | undefined; public pendingPromises: Promise = @@ -76,7 +76,6 @@ export class BusinessRuleMgr { } public checkField(fieldName: string) { - fieldName = fieldName.toLocaleLowerCase(); const thisCheck: ResolvablePromise = flippedPromise(); this.addPromise(thisCheck); @@ -84,7 +83,7 @@ export class BusinessRuleMgr { this.fieldChangePromises[fieldName].resolve('superseded'); this.fieldChangePromises[fieldName] = thisCheck; - var checks = [ + const checks = [ this.invokeRule('fieldChecks', fieldName, [this.resource]), this.checkUnique(fieldName), ]; @@ -128,20 +127,19 @@ export class BusinessRuleMgr { } private async checkUnique(fieldName: string): Promise { - var toOneFields = + const toOneFields = this.rules?.uniqueIn !== undefined - ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? - [] + ? this.rules?.uniqueIn[fieldName] ?? [] : []; - const results: RA> = toOneFields.map( + const results: RA>> = toOneFields.map( (uniqueRule) => { - var field = uniqueRule; - var fieldNames: string[] | null = [fieldName]; + let field = uniqueRule; + let fieldNames: string[] | null = [fieldName]; if (uniqueRule === null) { fieldNames = null; } else if (typeof uniqueRule != 'string') { - fieldNames = fieldNames.concat(uniqueRule.otherfields); + fieldNames = fieldNames.concat(uniqueRule.otherFields); field = uniqueRule.field; } return this.uniqueIn(field as string, fieldNames); @@ -238,7 +236,7 @@ export class BusinessRuleMgr { : undefined; const allNullOrUndefinedToOnes = fieldIds.reduce( - (previous, current, index) => + (previous, _current, index) => previous && fieldIsToOne[index] ? fieldIds[index] === null : false, true ); @@ -274,14 +272,14 @@ export class BusinessRuleMgr { }; if (toOneField != null) { - var localCollection = + const localCollection = this.resource.collection?.models !== undefined ? this.resource.collection.models.filter( (resource) => resource !== undefined ) : []; - var duplicates = localCollection.filter((resource) => + const duplicates = localCollection.filter((resource) => hasSameValues(resource) ); @@ -290,40 +288,55 @@ export class BusinessRuleMgr { return Promise.resolve(invalidResponse); } - return this.resource - .rgetPromise(toOneField) - .then((related: SpecifyResource) => { - if (!related) return Promise.resolve({ valid: true }); - var filters = {}; - for (var f = 0; f < fieldNames!.length; f++) { - filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; - } - const others = new this.resource.specifyModel.ToOneCollection({ - related: related, - field: toOneFieldInfo, - filters: filters, - }); + const relatedPromise: Promise> = + this.resource.rgetPromise(toOneField); - return others.fetch().then((fetchedCollection) => { - var inDatabase = fetchedCollection.models.filter( - (otherResource) => otherResource !== undefined - ); + return relatedPromise.then((related) => { + if (!related) return Promise.resolve({ valid: true }); + const filters: Partial> = {}; + for (let f = 0; f < fieldNames!.length; f++) { + filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; + } + const others = new this.resource.specifyModel.ToOneCollection({ + related: related, + field: toOneFieldInfo, + filters: filters as Partial< + { + readonly orderby: string; + readonly domainfilter: boolean; + } & SCHEMA['fields'] & + CommonFields & + IR + >, + }); - if (inDatabase.some((other) => hasSameValues(other))) { - return invalidResponse; - } else { - return { valid: true }; - } - }); + return others.fetch().then((fetchedCollection) => { + const inDatabase = fetchedCollection.models.filter( + (otherResource) => otherResource !== undefined + ); + + if (inDatabase.some((other) => hasSameValues(other))) { + return invalidResponse; + } else { + return { valid: true }; + } }); + }); } else { - var filters = {}; + const filters: Partial> = {}; - for (var f = 0; f < fieldNames.length; f++) { + for (let f = 0; f < fieldNames.length; f++) { filters[fieldNames[f]] = fieldIds[f] || fieldValues[f]; } const others = new this.resource.specifyModel.LazyCollection({ - filters: filters, + filters: filters as Partial< + { + readonly orderby: string; + readonly domainfilter: boolean; + } & SCHEMA['fields'] & + CommonFields & + IR + >, }); return others.fetch().then((fetchedCollection) => { if ( @@ -344,22 +357,25 @@ export class BusinessRuleMgr { fieldName: string | undefined, args: RA ): Promise { - if (this.rules === undefined || fieldName === undefined) { + if (this.rules === undefined) { return Promise.resolve(undefined); } - const rule = this.rules?.[ruleName]?.[fieldName]; + let rule = this.rules[ruleName]; - if (typeof rule !== 'function') { - return Promise.resolve(undefined); + if (rule === undefined) return Promise.resolve(undefined); + if (fieldName !== undefined) { + rule = rule[fieldName as keyof typeof rule]; } + if (rule === undefined) return Promise.resolve(undefined); + return Promise.resolve(rule.apply(this, args)); } } function attachBusinessRules(resource: SpecifyResource): void { - const businessRuleManager = new BusinessRuleMgr(resource); + const businessRuleManager = new BusinessRuleManager(resource); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); - overwriteReadOnly(resource, 'businessRuleMgr', businessRuleManager); + overwriteReadOnly(resource, 'businessRuleManager', businessRuleManager); businessRuleManager.setUpManager(); } diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 3bae18428af..07e9dd0eb47 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -10,7 +10,7 @@ import type { SerializedResource, TableFields, } from './helperTypes'; -import { BusinessRuleMgr } from './businessRules'; +import { BusinessRuleManager } from './businessRules'; import type { SaveBlockers } from './saveBlockers'; import type { Collection, SpecifyModel } from './specifyModel'; @@ -33,11 +33,11 @@ export type SpecifyResource = { readonly noBusinessRules: boolean; readonly _fetch?: unknown; readonly _save?: unknown; - readonly changed?: { [FIELDNAME in TableFields]?: string | number }; + readonly changed?: { [FIELD_NAME in TableFields]?: string | number }; readonly collection: Collection; - readonly businessRuleMgr?: - | BusinessRuleMgr - | BusinessRuleMgr; + readonly businessRuleManager?: + | BusinessRuleManager + | BusinessRuleManager; /* * Shorthand method signature is used to prevent * https://github.com/microsoft/TypeScript/issues/48339 diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts index 913d759e570..e17e0ec5529 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyModel.ts @@ -98,8 +98,6 @@ export type Collection = { getTotalCount(): Promise; // eslint-disable-next-line @typescript-eslint/naming-convention toJSON>(): RA; - any(callback: (others: SpecifyResource) => boolean): boolean; - each(callback: (other: SpecifyResource) => void): void; add(resource: RA> | SpecifyResource): void; remove(resource: SpecifyResource): void; fetch(filter?: { readonly limit: number }): Promise>; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index 5cc807224c3..00354bab710 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -8,13 +8,13 @@ "role":[ { "field":"accession", - "otherfields":[ + "otherFields":[ "agent" ] }, { "field":"repositoryagreement", - "otherfields":[ + "otherFields":[ "agent" ] } @@ -22,13 +22,13 @@ "agent":[ { "field":"accession", - "otherfields":[ + "otherFields":[ "role" ] }, { "field":"repositoryagreement", - "otherfields":[ + "otherFields":[ "role" ] } @@ -51,7 +51,7 @@ "role":[ { "field":"borrow", - "otherfields":[ + "otherFields":[ "agent" ] } @@ -59,7 +59,7 @@ "agent":[ { "field":"borrow", - "otherfields":[ + "otherFields":[ "role" ] } @@ -108,7 +108,7 @@ "role":[ { "field":"disposal", - "otherfields":[ + "otherFields":[ "agent" ] } @@ -116,7 +116,7 @@ "agent":[ { "field":"disposal", - "otherfields":[ + "otherFields":[ "role" ] } @@ -146,7 +146,7 @@ "role": [ { "field":"gift", - "otherfields":[ + "otherFields":[ "agent" ] } @@ -154,7 +154,7 @@ "agent":[ { "field":"gift", - "otherfields":[ + "otherFields":[ "role" ] } @@ -179,7 +179,7 @@ "role":[ { "field":"loan", - "otherfields":[ + "otherFields":[ "agent" ] } @@ -187,7 +187,7 @@ "agent":[ { "field":"loan", - "otherfields":[ + "otherFields":[ "role" ] } From 029d2da6a73b96794109cfda8522feddf161a5d4 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 3 Mar 2023 08:50:17 -0600 Subject: [PATCH 18/50] Fix other misc. Typescript errors --- .../js_src/lib/components/DataModel/businessRuleDefs.ts | 2 +- .../js_src/lib/components/DataModel/businessRules.ts | 7 ++++--- .../js_src/lib/components/DataModel/legacyTypes.ts | 4 +--- .../frontend/js_src/lib/components/FormCells/FormTable.tsx | 3 ++- specifyweb/frontend/js_src/lib/components/Forms/Save.tsx | 2 +- .../frontend/js_src/lib/components/PickLists/index.tsx | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 60d0738a1bc..7c472e7028e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -271,7 +271,7 @@ export const businessRuleDefs = f.store( }, LoanReturnPreparation: { onRemoved: ( - loanReturnPrep: SpecifyResource, + _loanReturnPrep: SpecifyResource, collection: Collection ): void => updateLoanPrep(collection), diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index da369b23520..1ba9d1e2b2d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -1,5 +1,5 @@ import { IR, overwriteReadOnly, RA } from '../../utils/types'; -import { AnySchema, AnyTree, CommonFields } from './helperTypes'; +import { AnySchema, AnyTree, CommonFields, TableFields } from './helperTypes'; import { SpecifyResource } from './legacyTypes'; import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; import { flippedPromise, ResolvablePromise } from '../../utils/promise'; @@ -27,7 +27,7 @@ export function enableBusinessRules(e: boolean) { export class BusinessRuleManager { private readonly resource: SpecifyResource; - private readonly rules: BusinessRuleDefs | undefined; + private readonly rules: BusinessRuleDefs | undefined; public pendingPromises: Promise = Promise.resolve(null); private fieldChangePromises: { @@ -129,7 +129,8 @@ export class BusinessRuleManager { private async checkUnique(fieldName: string): Promise { const toOneFields = this.rules?.uniqueIn !== undefined - ? this.rules?.uniqueIn[fieldName] ?? [] + ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? + [] : []; const results: RA>> = toOneFields.map( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 07e9dd0eb47..1127163d6e2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -35,9 +35,7 @@ export type SpecifyResource = { readonly _save?: unknown; readonly changed?: { [FIELD_NAME in TableFields]?: string | number }; readonly collection: Collection; - readonly businessRuleManager?: - | BusinessRuleManager - | BusinessRuleManager; + readonly businessRuleManager?: BusinessRuleManager; /* * Shorthand method signature is used to prevent * https://github.com/microsoft/TypeScript/issues/48339 diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index a3cbcde98f5..9f6e1078e9f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -405,7 +405,8 @@ export function FormTable({ ? undefined : isDependent ? (): void => { - const resource = new relationship.relatedModel.Resource(); + const resource = + new relationship.relatedModel.Resource() as SpecifyResource; handleAddResources([resource]); } : (): void => diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index 906e5fd46b0..15d8f2a567b 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -145,7 +145,7 @@ export function SaveButton({ } loading( - (resource.businessRuleMgr?.pendingPromises ?? Promise.resolve()).then( + (resource.businessRuleManager?.pendingPromises ?? Promise.resolve()).then( () => { const blockingResources = Array.from( resource.saveBlockers?.blockingResources ?? [] diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx index b6831b6705f..80a5dcd3f6f 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx @@ -77,7 +77,7 @@ export function PickListComboBox( // Listen for field value change React.useEffect(() => { if (props.resource === undefined) return undefined; - void props.resource.businessRuleMgr?.checkField(props.field.name); + void props.resource.businessRuleManager?.checkField(props.field.name); return resourceOn( props.resource, `change:${props.field.name}`, From 69903645ef4fd99abdffe242c5fc874f64c40d2f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 6 Mar 2023 13:42:59 -0600 Subject: [PATCH 19/50] Remove globalEvents in businessRules.ts The change here was necessitated by the changes in aa323a5 --- .../js_src/lib/components/DataModel/businessRules.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 1ba9d1e2b2d..c40af1440f6 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -11,16 +11,9 @@ import { formatConjunction } from '../Atoms/Internationalization'; import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; -import { globalEvents } from '../../utils/ajax/specifyApi'; let enabled: boolean = true; -globalEvents.on('initResource', (resource) => { - enabled && !resource.noBusinessRules - ? attachBusinessRules(resource) - : undefined; -}); - export function enableBusinessRules(e: boolean) { return (enabled = e); } @@ -373,7 +366,9 @@ export class BusinessRuleManager { } } -function attachBusinessRules(resource: SpecifyResource): void { +export function attachBusinessRules( + resource: SpecifyResource +): void { const businessRuleManager = new BusinessRuleManager(resource); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); overwriteReadOnly(resource, 'businessRuleManager', businessRuleManager); From 684548a494ee58e8eb25afba18db1223c852db87 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 7 Mar 2023 10:17:39 -0600 Subject: [PATCH 20/50] Create a new a new object for assigning uniqueness rules Rather than using mutation --- .../js_src/lib/components/Core/Main.tsx | 2 - .../components/DataModel/businessRuleDefs.ts | 547 +++++++++--------- .../lib/components/DataModel/businessRules.js | 281 --------- .../lib/components/DataModel/businessRules.ts | 8 +- 4 files changed, 268 insertions(+), 570 deletions(-) delete mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx index bed7b9ca318..e9492b7f51a 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx @@ -10,7 +10,6 @@ import { userText } from '../../localization/user'; import { f } from '../../utils/functools'; import type { GetOrSet, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; -import { enableBusinessRules } from '../DataModel/businessRules'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { Header } from '../Header'; import type { MenuItemName } from '../Header/menuItemDefinitions'; @@ -42,7 +41,6 @@ export function Main({ const mainRef = React.useRef(null); React.useEffect(() => { - enableBusinessRules(true); console.groupEnd(); }, []); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 7c472e7028e..bd40123a110 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -1,5 +1,4 @@ -import { f } from '../../utils/functools'; -import { overwriteReadOnly, RA } from '../../utils/types'; +import { RA } from '../../utils/types'; import { AnySchema, TableFields } from './helperTypes'; import { checkPrepAvailability, @@ -56,302 +55,290 @@ type MappedBusinessRuleDefs = { [TABLE in keyof Tables]?: BusinessRuleDefs; }; -function assignUniquenessRules( - mappedRules: MappedBusinessRuleDefs -): MappedBusinessRuleDefs { - Object.keys(uniqueRules).forEach((table) => { - if (mappedRules[table as keyof Tables] === undefined) - overwriteReadOnly(mappedRules, table, {}); +export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { + BorrowMaterial: { + fieldChecks: { + quantityreturned: ( + borrowMaterial: SpecifyResource + ): void => { + const returned = borrowMaterial.get('quantityReturned'); + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + var newVal: number | undefined = undefined; + if ( + typeof quantity === 'number' && + typeof returned === 'number' && + returned > quantity + ) { + newVal = quantity; + } + if (returned && resolved && returned > resolved) { + newVal = resolved; + } - overwriteReadOnly( - mappedRules[table as keyof Tables]!, - 'uniqueIn', - uniquenessRules[table as keyof Tables] - ); - }); - return mappedRules; -} - -export const businessRuleDefs = f.store( - (): MappedBusinessRuleDefs => - assignUniquenessRules({ - BorrowMaterial: { - fieldChecks: { - quantityreturned: ( - borrowMaterial: SpecifyResource - ): void => { - const returned = borrowMaterial.get('quantityReturned'); - const resolved = borrowMaterial.get('quantityResolved'); - const quantity = borrowMaterial.get('quantity'); - var newVal: number | undefined = undefined; - if ( - typeof quantity === 'number' && - typeof returned === 'number' && - returned > quantity - ) { - newVal = quantity; - } - if (returned && resolved && returned > resolved) { - newVal = resolved; - } - - newVal && borrowMaterial.set('quantityReturned', newVal); - }, - quantityresolved: ( - borrowMaterial: SpecifyResource - ): void => { - const resolved = borrowMaterial.get('quantityResolved'); - const quantity = borrowMaterial.get('quantity'); - const returned = borrowMaterial.get('quantityReturned'); - let newVal: number | undefined = undefined; - if (resolved && quantity && resolved > quantity) { - newVal = quantity; - } - if (resolved && returned && resolved < returned) { - newVal = returned; - } + newVal && borrowMaterial.set('quantityReturned', newVal); + }, + quantityresolved: ( + borrowMaterial: SpecifyResource + ): void => { + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + const returned = borrowMaterial.get('quantityReturned'); + let newVal: number | undefined = undefined; + if (resolved && quantity && resolved > quantity) { + newVal = quantity; + } + if (resolved && returned && resolved < returned) { + newVal = returned; + } - if (typeof newVal === 'number') - borrowMaterial.set('quantityResolved', newVal); - }, - }, + if (typeof newVal === 'number') + borrowMaterial.set('quantityResolved', newVal); }, + }, + }, - CollectionObject: { - customInit: function ( - collectionObject: SpecifyResource - ): void { - const ceField = - collectionObject.specifyModel.getField('collectingEvent'); - if ( - ceField?.isDependent() && - collectionObject.get('collectingEvent') == undefined - ) { - collectionObject.set( - 'collectingEvent', - new schema.models.CollectingEvent.Resource() + CollectionObject: { + customInit: function ( + collectionObject: SpecifyResource + ): void { + const ceField = collectionObject.specifyModel.getField('collectingEvent'); + if ( + ceField?.isDependent() && + collectionObject.get('collectingEvent') == undefined + ) { + collectionObject.set( + 'collectingEvent', + new schema.models.CollectingEvent.Resource() + ); + } + }, + }, + + Determination: { + customInit: (determinaton: SpecifyResource): void => { + if (determinaton.isNew()) { + const setCurrent = () => { + determinaton.set('isCurrent', true); + if (determinaton.collection != null) { + determinaton.collection.models.map( + (other: SpecifyResource) => { + if (other.cid !== determinaton.cid) { + other.set('isCurrent', false); + } + } ); } - }, - }, - - Determination: { - customInit: (determinaton: SpecifyResource): void => { - if (determinaton.isNew()) { - const setCurrent = () => { - determinaton.set('isCurrent', true); - if (determinaton.collection != null) { - determinaton.collection.models.map( - (other: SpecifyResource) => { - if (other.cid !== determinaton.cid) { - other.set('isCurrent', false); - } - } + }; + if (determinaton.collection !== null) setCurrent(); + determinaton.on('add', setCurrent); + } + }, + fieldChecks: { + taxon: ( + determination: SpecifyResource + ): Promise => { + return determination + .rgetPromise('taxon', true) + .then((taxon: SpecifyResource | null) => { + const getLastAccepted = ( + taxon: SpecifyResource + ): Promise> => { + return taxon + .rgetPromise('acceptedTaxon', true) + .then((accepted) => + accepted === null ? taxon : getLastAccepted(accepted) ); - } }; - if (determinaton.collection !== null) setCurrent(); - determinaton.on('add', setCurrent); - } - }, - fieldChecks: { - taxon: ( - determination: SpecifyResource - ): Promise => { - return determination - .rgetPromise('taxon', true) - .then((taxon: SpecifyResource | null) => { - const getLastAccepted = ( - taxon: SpecifyResource - ): Promise> => { - return taxon - .rgetPromise('acceptedTaxon', true) - .then((accepted) => - accepted === null ? taxon : getLastAccepted(accepted) - ); - }; - return taxon === null - ? { - valid: true, - action: () => determination.set('preferredTaxon', null), - } - : { - valid: true, - action: async () => - determination.set( - 'preferredTaxon', - await getLastAccepted(taxon) - ), - }; - }); - }, - iscurrent: ( - determination: SpecifyResource - ): Promise | void => { - if ( - determination.get('isCurrent') && - determination.collection != null - ) { - determination.collection.models.map( - (other: SpecifyResource) => { - if (other.cid !== determination.cid) { - other.set('isCurrent', false); - } + return taxon === null + ? { + valid: true, + action: () => determination.set('preferredTaxon', null), } - ); - } - if ( - determination.collection != null && - !determination.collection.models.some( - (c: SpecifyResource) => c.get('isCurrent') - ) - ) { - determination.set('isCurrent', true); - } - return Promise.resolve({ valid: true }); - }, - }, + : { + valid: true, + action: async () => + determination.set( + 'preferredTaxon', + await getLastAccepted(taxon) + ), + }; + }); }, - DNASequence: { - fieldChecks: { - genesequence: (dnaSequence: SpecifyResource): void => { - const current = dnaSequence.get('geneSequence'); - if (current === null) return; - const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; - for (let i = 0; i < current.length; i++) { - const char = current.at(i)?.toLowerCase().trim(); - if (char !== '') { - switch (char) { - case 'a': - countObj['a'] += 1; - break; - case 't': - countObj['t'] += 1; - break; - case 'g': - countObj['g'] += 1; - break; - case 'c': - countObj['c'] += 1; - break; - default: - countObj['ambiguous'] += 1; - } + iscurrent: ( + determination: SpecifyResource + ): Promise | void => { + if ( + determination.get('isCurrent') && + determination.collection != null + ) { + determination.collection.models.map( + (other: SpecifyResource) => { + if (other.cid !== determination.cid) { + other.set('isCurrent', false); } } - dnaSequence.set('compA', countObj['a']); - dnaSequence.set('compT', countObj['t']); - dnaSequence.set('compG', countObj['g']); - dnaSequence.set('compC', countObj['c']); - dnaSequence.set('ambiguousResidues', countObj['ambiguous']); - dnaSequence.set( - 'totalResidues', - countObj['a'] + - countObj['t'] + - countObj['g'] + - countObj['c'] + - countObj['ambiguous'] - ); - }, - }, + ); + } + if ( + determination.collection != null && + !determination.collection.models.some( + (c: SpecifyResource) => c.get('isCurrent') + ) + ) { + determination.set('isCurrent', true); + } + return Promise.resolve({ valid: true }); + }, + }, + }, + DNASequence: { + fieldChecks: { + genesequence: (dnaSequence: SpecifyResource): void => { + const current = dnaSequence.get('geneSequence'); + if (current === null) return; + const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; + for (let i = 0; i < current.length; i++) { + const char = current.at(i)?.toLowerCase().trim(); + if (char !== '') { + switch (char) { + case 'a': + countObj['a'] += 1; + break; + case 't': + countObj['t'] += 1; + break; + case 'g': + countObj['g'] += 1; + break; + case 'c': + countObj['c'] += 1; + break; + default: + countObj['ambiguous'] += 1; + } + } + } + dnaSequence.set('compA', countObj['a']); + dnaSequence.set('compT', countObj['t']); + dnaSequence.set('compG', countObj['g']); + dnaSequence.set('compC', countObj['c']); + dnaSequence.set('ambiguousResidues', countObj['ambiguous']); + dnaSequence.set( + 'totalResidues', + countObj['a'] + + countObj['t'] + + countObj['g'] + + countObj['c'] + + countObj['ambiguous'] + ); }, - GiftPreparation: { - fieldChecks: { - quantity: (iprep: SpecifyResource): void => { - checkPrepAvailability(iprep); - }, - }, + }, + }, + GiftPreparation: { + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + checkPrepAvailability(iprep); }, - LoanPreparation: { - fieldChecks: { - quantity: (iprep: SpecifyResource): void => { - checkPrepAvailability(iprep); - }, - }, + }, + }, + LoanPreparation: { + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + checkPrepAvailability(iprep); }, - LoanReturnPreparation: { - onRemoved: ( - _loanReturnPrep: SpecifyResource, - collection: Collection - ): void => updateLoanPrep(collection), + }, + }, + LoanReturnPreparation: { + onRemoved: ( + _loanReturnPrep: SpecifyResource, + collection: Collection + ): void => updateLoanPrep(collection), - customInit: ( - resource: SpecifyResource - ): void => { - resource.get('quantityReturned') == null && - resource.set('quantityReturned', 0); - resource.get('quantityResolved') == null && - resource.set('quantityResolved', 0); - }, - fieldChecks: { - quantityreturned: ( - loanReturnPrep: SpecifyResource - ): void => { - const returned = loanReturnPrep.get('quantityReturned'); - const previousReturned = interactionCache().previousReturned[ - Number(loanReturnPrep.cid) - ] - ? interactionCache().previousReturned[Number(loanReturnPrep.cid)] + customInit: (resource: SpecifyResource): void => { + resource.get('quantityReturned') == null && + resource.set('quantityReturned', 0); + resource.get('quantityResolved') == null && + resource.set('quantityResolved', 0); + }, + fieldChecks: { + quantityreturned: ( + loanReturnPrep: SpecifyResource + ): void => { + const returned = loanReturnPrep.get('quantityReturned'); + const previousReturned = interactionCache().previousReturned[ + Number(loanReturnPrep.cid) + ] + ? interactionCache().previousReturned[Number(loanReturnPrep.cid)] + : 0; + if (returned !== null && returned != previousReturned) { + const delta = returned - previousReturned; + let resolved = loanReturnPrep.get('quantityResolved'); + const totalLoaned = getTotalLoaned(loanReturnPrep); + const totalResolved = getTotalResolved(loanReturnPrep); + const max = + typeof totalLoaned === 'number' && typeof totalResolved === 'number' + ? totalLoaned - totalResolved : 0; - if (returned !== null && returned != previousReturned) { - const delta = returned - previousReturned; - let resolved = loanReturnPrep.get('quantityResolved'); - const totalLoaned = getTotalLoaned(loanReturnPrep); - const totalResolved = getTotalResolved(loanReturnPrep); - const max = - typeof totalLoaned === 'number' && - typeof totalResolved === 'number' - ? totalLoaned - totalResolved - : 0; - if (resolved !== null && delta + resolved > max) { - loanReturnPrep.set('quantityReturned', previousReturned); - } else { - resolved = loanReturnPrep.get('quantityResolved')! + delta; - interactionCache().previousResolved[ - Number(loanReturnPrep.cid) - ] = resolved; - loanReturnPrep.set('quantityResolved', resolved); - } - interactionCache().previousReturned[Number(loanReturnPrep.cid)] = - loanReturnPrep.get('quantityReturned'); - updateLoanPrep(loanReturnPrep.collection); - } - }, - quantityresolved: ( - loanReturnPrep: SpecifyResource - ): void => { - const resolved = loanReturnPrep.get('quantityResolved'); - const previousResolved = interactionCache().previousResolved[ - Number(loanReturnPrep.cid) - ] - ? interactionCache().previousResolved[Number(loanReturnPrep.cid)] + if (resolved !== null && delta + resolved > max) { + loanReturnPrep.set('quantityReturned', previousReturned); + } else { + resolved = loanReturnPrep.get('quantityResolved')! + delta; + interactionCache().previousResolved[Number(loanReturnPrep.cid)] = + resolved; + loanReturnPrep.set('quantityResolved', resolved); + } + interactionCache().previousReturned[Number(loanReturnPrep.cid)] = + loanReturnPrep.get('quantityReturned'); + updateLoanPrep(loanReturnPrep.collection); + } + }, + quantityresolved: ( + loanReturnPrep: SpecifyResource + ): void => { + const resolved = loanReturnPrep.get('quantityResolved'); + const previousResolved = interactionCache().previousResolved[ + Number(loanReturnPrep.cid) + ] + ? interactionCache().previousResolved[Number(loanReturnPrep.cid)] + : 0; + if (resolved != previousResolved) { + const returned = loanReturnPrep.get('quantityReturned'); + const totalLoaned = getTotalLoaned(loanReturnPrep); + const totalResolved = getTotalResolved(loanReturnPrep); + const max = + typeof totalLoaned === 'number' && typeof totalResolved === 'number' + ? totalLoaned - totalResolved : 0; - if (resolved != previousResolved) { - const returned = loanReturnPrep.get('quantityReturned'); - const totalLoaned = getTotalLoaned(loanReturnPrep); - const totalResolved = getTotalResolved(loanReturnPrep); - const max = - typeof totalLoaned === 'number' && - typeof totalResolved === 'number' - ? totalLoaned - totalResolved - : 0; - if (resolved !== null && returned !== null) { - if (resolved > max) { - loanReturnPrep.set('quantityResolved', previousResolved); - } - if (resolved < returned) { - interactionCache().previousReturned[ - Number(loanReturnPrep.cid) - ] = resolved; - loanReturnPrep.set('quantityReturned', resolved); - } - } - interactionCache().previousResolved[Number(loanReturnPrep.cid)] = - loanReturnPrep.get('quantityResolved'); - updateLoanPrep(loanReturnPrep.collection); + if (resolved !== null && returned !== null) { + if (resolved > max) { + loanReturnPrep.set('quantityResolved', previousResolved); } - }, - }, + if (resolved < returned) { + interactionCache().previousReturned[Number(loanReturnPrep.cid)] = + resolved; + loanReturnPrep.set('quantityReturned', resolved); + } + } + interactionCache().previousResolved[Number(loanReturnPrep.cid)] = + loanReturnPrep.get('quantityResolved'); + updateLoanPrep(loanReturnPrep.collection); + } }, - } as const) + }, + }, +}; + +export const businessRuleDefs: MappedBusinessRuleDefs = Object.fromEntries( + Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }).map( + (table: keyof Tables) => { + const ruleDefs = + nonUniqueBusinessRuleDefs[table] === undefined + ? { uniqueIn: uniqueRules[table] } + : Object.assign({}, nonUniqueBusinessRuleDefs[table], { + uniqueIn: uniqueRules[table], + }); + return [table, ruleDefs]; + } + ) ); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js deleted file mode 100644 index c3935440e72..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js +++ /dev/null @@ -1,281 +0,0 @@ -import _ from 'underscore'; - -import {formsText} from '../../localization/forms'; -import {flippedPromise} from '../../utils/promise'; -import {formatConjunction} from '../Atoms/Internationalization'; -import {isTreeResource} from '../InitialContext/treeRanks'; -import {businessRuleDefs} from './businessRuleDefs'; -import {idFromUrl} from './resource'; -import {SaveBlockers} from './saveBlockers'; -import {initializeTreeRecord, treeBusinessRules} from './treeBusinessRules'; - -let enabled = true; - - export function attachBusinessRules(resource) { - if(!enabled) return; - let mgr; - mgr = resource.businessRuleMgr = new BusinessRuleMgr(resource); - mgr.setupEvents(); - resource.saveBlockers = new SaveBlockers(resource); - if(isTreeResource(resource)) - initializeTreeRecord(resource); - mgr.doCustomInit(); - } - - function BusinessRuleMgr(resource) { - this.resource = resource; - this.rules = businessRuleDefs[this.resource.specifyModel.name]; - this.pending = Promise.resolve(null); - this.fieldChangePromises = {}; - this.watchers = {}; - } - - _(BusinessRuleMgr.prototype).extend({ - addPromise(promise) { - this.pending = Promise.allSettled([this.pending, promise]).then(()=>null); - }, - - setupEvents() { - this.resource.on('change', this.changed, this); - this.resource.on('add', this.added, this); - this.resource.on('remove', this.removed, this); - }, - - invokeRule(ruleName, fieldName, args) { - /* - * Var resource = this.resource; - * var msg = 'BR ' + ruleName + (fieldName ? '[' + fieldName + '] ': ' ') + 'finished on'; - * promise.then(function(result) { console.debug(msg, resource, {args: args, result: result}); }); - */ - return this._invokeRule(ruleName, fieldName, args); - }, - async _invokeRule(ruleName, fieldName, args) { - let rule = this.rules && this.rules[ruleName]; - if (!rule) return `no rule: ${ ruleName}`; - if (fieldName) { - rule = rule[fieldName]; - if (!rule) return `no rule: ${ ruleName } for: ${ fieldName}`; - } - return rule.apply(this, args); - }, - - doCustomInit() { - this.addPromise( - this.invokeRule('customInit', null, [this.resource])); - }, - - changed(resource) { - if (!resource._fetch && !resource._save) { - _.each(resource.changed, function(__, fieldName) { - this.checkField(fieldName); - }, this); - } - }, - - added(resource, collection) { - if (resource.specifyModel && resource.specifyModel.getField('ordinal')) { - resource.set('ordinal', collection.indexOf(resource)); - } - this.addPromise( - this.invokeRule('onAdded', null, [resource, collection])); - }, - - removed(resource, collection) { - this.addPromise( - this.invokeRule('onRemoved', null, [resource, collection])); - }, - - checkField(fieldName) { - fieldName = fieldName.toLowerCase(); - - const thisCheck = flippedPromise(); - /* - * ThisCheck.promise.then(function(result) { console.debug('BR finished checkField', - * {field: fieldName, result: result}); }); - */ - this.addPromise(thisCheck); - - /* - * If another change happens while the previous check is pending, - * that check is superseded by checking the new value. - */ - this.fieldChangePromises[fieldName] && this.fieldChangePromises[fieldName].resolve('superseded'); - this.fieldChangePromises[fieldName] = thisCheck; - - const checks = [ - this.invokeRule('customChecks', fieldName, [this.resource]), - this.checkUnique(fieldName) - ]; - - if(isTreeResource(this.resource)) - checks.push(treeBusinessRules(this.resource, fieldName)); - - Promise.all(checks).then((results) => - // Only process these results if the change has not been superseded - (thisCheck === this.fieldChangePromises[fieldName]) && - this.processCheckFieldResults(fieldName, results) - ).then(() => { thisCheck.resolve('finished'); }); - }, - async processCheckFieldResults(fieldName, results) { - const resource = this.resource; - return Promise.all(results.map((result) => { - if (!result) return null; - if (result.valid === false) { - resource.saveBlockers?.add(result.key, fieldName, result.reason); - } else if (result.valid === true) { - resource.saveBlockers?.remove(result.key); - } - return result.action && result.action(); - })); - }, - async checkUnique(fieldName) { - const _this = this; - let results; - if (this.rules && this.rules.unique && _(this.rules.unique).contains(fieldName)) { - // Field value is required to be globally unique. - results = [uniqueIn(null, this.resource, fieldName)]; - } else { - let toOneFields = (this.rules && this.rules.uniqueIn && this.rules.uniqueIn[fieldName]) || []; - if (!_.isArray(toOneFields)) toOneFields = [toOneFields]; - results = _.map(toOneFields, (def) => { - let field = def; - let fieldNames = [fieldName]; - if (typeof def !== 'string') { - fieldNames = fieldNames.concat(def.otherfields); - field = def.field; - } - return uniqueIn(field, _this.resource, fieldNames); - }); - } - Promise.all(results).then((results) => { - _.chain(results).pluck('localDupes').compact().flatten().each((dup) => { - const event = `${dup.cid }:${ fieldName}`; - if (_this.watchers[event]) return; - _this.watchers[event] = dup.on('change remove', () => { - _this.checkField(fieldName); - }); - }); - }); - return combineUniquenessResults(results).then((result) => { - // Console.debug('BR finished checkUnique for', fieldName, result); - result.key = `br-uniqueness-${ fieldName}`; - return result; - }); - } - }); - - - var combineUniquenessResults = async function(deferredResults) { - return Promise.all(deferredResults).then((results) => { - const invalids = _.filter(results, (result) => !result.valid); - return invalids.length === 0 - ? {valid: true} - : { - valid: false, - reason: formatConjunction(_(invalids).pluck('reason')) - }; - }); - }; - - const getUniqueInInvalidReason = function(parentFldInfo, fldInfo) { - if (fldInfo.length > 1) - return parentFldInfo - ? formsText.valuesOfMustBeUniqueToField({ - values: formatConjunction(fldInfo.map((fld) => fld.label)), - fieldName: parentFldInfo.label, - }) - : formsText.valuesOfMustBeUniqueToDatabase({ - values: formatConjunction(fldInfo.map((fld) => fld.label)) - }); - else - return parentFldInfo - ? formsText.valueMustBeUniqueToField({fieldName: parentFldInfo.label}) - : formsText.valueMustBeUniqueToDatabase(); - }; - - var uniqueIn = function(toOneField, resource, valueFieldArgument) { - const valueField = Array.isArray(valueFieldArgument) ? valueFieldArgument : [valueFieldArgument]; - const value = _.map(valueField, (v) => resource.get(v)); - const valueFieldInfo = _.map(valueField, (v) => resource.specifyModel.getField(v)); - const valueIsToOne = _.map(valueFieldInfo, (fi) => fi.type === 'many-to-one'); - const valueId = _.map(value, (v, index) => { - if (valueIsToOne[index]) { - if (_.isNull(v) || v === undefined) { - return null; - } else { - return _.isString(v) ? idFromUrl(v) : v.id; - } - } else { - return undefined; - } - }); - - const toOneFieldInfo = toOneField ? resource.specifyModel.getField(toOneField) : undefined; - const valid = { - valid: true - }; - const invalid = { - valid: false, - reason: getUniqueInInvalidReason(toOneFieldInfo, valueFieldInfo) - }; - - const allNullOrUndefinedToOnes = _.reduce(valueId, (result, v, index) => result && - valueIsToOne[index] ? _.isNull(valueId[index]) : false, true); - if (allNullOrUndefinedToOnes) { - return Promise.resolve(valid); - } - - const hasSameValue = function(other, value, valueField, valueIsToOne, valueId) { - if ((other.id != null) && other.id === resource.id) return false; - if (other.cid === resource.cid) return false; - const otherValue = other.get(valueField); - return valueIsToOne && otherValue !== undefined && !(_.isString(otherValue)) ? Number.parseInt(otherValue.id) === Number.parseInt(valueId) : value === otherValue; - }; - - const hasSameValues = function(other, values, valueFields, valuesAreToOne, valueIds) { - return _.reduce(values, (result, value_, index) => result && hasSameValue(other, value_, valueFields[index], valuesAreToOne[index], valueIds[index]), true); - }; - - if (toOneField == null) { - const filters = {}; - for (const [f, element] of valueField.entries()) { - filters[element] = valueId[f] || value[f]; - } - const others = new resource.specifyModel.LazyCollection({ - filters - }); - return others.fetch().then(() => _.any(others.models, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)) ? invalid : valid); - } else { - const haveLocalColl = (resource.collection && resource.collection.related && - toOneFieldInfo.relatedModel === resource.collection.related.specifyModel); - - const localCollection = haveLocalColl ? _.compact(resource.collection.models) : []; - const dupes = _.filter(localCollection, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)); - if (dupes.length > 0) { - invalid.localDupes = dupes; - return Promise.resolve(invalid); - } - return resource.rget(toOneField).then((related) => { - if (!related) return valid; - const filters = {}; - for (const [f, element] of valueField.entries()) { - filters[element] = valueId[f] || value[f]; - } - const others = new resource.specifyModel.ToOneCollection({ - related, - field: toOneFieldInfo, - filters - }); - return others.fetch().then(() => { - let inDatabase = others.chain().compact(); - inDatabase = haveLocalColl ? inDatabase.filter((other) => !(resource.collection.get(other.id))).value() : inDatabase.value(); - return _.any(inDatabase, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)) ? invalid : valid; - }); - }); - } - }; - - -export function enableBusinessRules(e) { - return enabled = e; -} diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index c40af1440f6..401e319fa52 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -12,12 +12,6 @@ import { formsText } from '../../localization/forms'; import { LiteralField, Relationship } from './specifyField'; import { idFromUrl } from './resource'; -let enabled: boolean = true; - -export function enableBusinessRules(e: boolean) { - return (enabled = e); -} - export class BusinessRuleManager { private readonly resource: SpecifyResource; private readonly rules: BusinessRuleDefs | undefined; @@ -30,7 +24,7 @@ export class BusinessRuleManager { public constructor(resource: SpecifyResource) { this.resource = resource; - this.rules = businessRuleDefs()[this.resource.specifyModel.name]; + this.rules = businessRuleDefs[this.resource.specifyModel.name]; } private addPromise( From eea0b3cf349e56c5b9d4426bd0efe2d66953d7cd Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 10 Mar 2023 11:54:45 -0600 Subject: [PATCH 21/50] Set Taxon.isAccepted on save to whether it is synonimized or not --- specifyweb/businessrules/tree_rules.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specifyweb/businessrules/tree_rules.py b/specifyweb/businessrules/tree_rules.py index 4f5f51aec4a..417e9c8a3f2 100644 --- a/specifyweb/businessrules/tree_rules.py +++ b/specifyweb/businessrules/tree_rules.py @@ -17,3 +17,7 @@ def cannot_delete_root_treedefitem(sender, obj): "id" : obj.id }}) +@orm_signal_handler('pre_save') +def set_is_accepted_if_prefereed(sender, obj): + if hasattr(obj, 'isaccepted'): + obj.isaccepted = obj.accepted_id == None From ced0397e2e9a2e20a4612fbe07e9a04c465a00a5 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 10 Mar 2023 16:32:04 -0600 Subject: [PATCH 22/50] Begin writing business rule tests --- .../DataModel/__tests__/businessRules.test.ts | 116 ++++++++++++++++++ .../components/DataModel/businessRuleDefs.ts | 7 +- .../lib/components/DataModel/businessRules.ts | 5 +- .../lib/components/DataModel/legacyTypes.ts | 2 - .../components/DataModel/uniquness_rules.json | 5 - 5 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts new file mode 100644 index 00000000000..acc658e6919 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -0,0 +1,116 @@ +import { overrideAjax } from '../../../tests/ajax'; +import { mockTime, requireContext } from '../../../tests/helpers'; +import { businessRuleDefs } from '../businessRuleDefs'; +import { SerializedModel } from '../helperTypes'; +import { getResourceApiUrl } from '../resource'; +import { schema } from '../schema'; +import { Determination } from '../types'; + +mockTime(); +requireContext(); + +test('uniqueness rules assigned correctly', async () => { + const accessionAgentUniquenessRules = { + role: [ + { + field: 'accession', + otherFields: ['agent'], + }, + { + field: 'repositoryagreement', + otherFields: ['agent'], + }, + ], + agent: [ + { + field: 'accession', + otherFields: ['role'], + }, + { + field: 'repositoryagreement', + otherFields: ['role'], + }, + ], + }; + expect(businessRuleDefs.AccessionAgent?.uniqueIn).toBe( + accessionAgentUniquenessRules + ); +}); + +const determinationId = 321; +const determinationUrl = getResourceApiUrl('Determination', determinationId); +const determinationResponse: Partial> = { + resource_uri: determinationUrl, + id: determinationId, +}; + +const collectionObjectId = 220; +const collectionObjectUrl = getResourceApiUrl( + 'CollectionObject', + collectionObjectId +); +const collectionObjectResponse = { + id: collectionObjectId, + resource_uri: collectionObjectUrl, + catalognumber: '000022002', + collection: getResourceApiUrl('Collection', 4), + determinations: [determinationResponse], +}; + +overrideAjax(collectionObjectUrl, collectionObjectResponse); +overrideAjax(determinationUrl, determinationResponse); + +test('collectionObject customInit', () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + expect(resource.get('collectingEvent')).toBeDefined(); +}); + +describe('determination business rules', () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); + test('determination customInit', async () => { + await determination.fetch(); + expect(determination.get('isCurrent')).toBe(true); + }); + test('only one determination isCurrent', async () => { + await resource.rgetCollection('determinations').then((collection) => { + collection.add(new schema.models.Determination.Resource()); + }); + expect(determination.get('isCurrent')).toBe(false); + }); + test('determination taxon field check', async () => { + const taxonId = 19345; + const taxonUrl = getResourceApiUrl('Taxon', taxonId); + const taxonResponse = { + resource_uri: getResourceApiUrl('Taxon', taxonUrl), + id: taxonId, + name: 'melas', + fullName: 'Ameiurus melas', + }; + overrideAjax(taxonUrl, taxonResponse); + determination.set( + 'taxon', + new schema.models.Taxon.Resource({ + id: taxonId, + }) + ); + expect(determination.get('preferredTaxon')).toBe(taxonUrl); + }); +}); + +test('dnaSequence genesequence fieldCheck', async () => { + const dnaSequence = new schema.models.DNASequence.Resource({ + id: 1, + }); + dnaSequence.set('geneSequence', 'cat123gaaz'); + + expect(dnaSequence.get('totalResidues')).toBe(10); + expect(dnaSequence.get('compA')).toBe(3); + expect(dnaSequence.get('ambiguousResidues')).toBe(4); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index bd40123a110..4944c9a3f96 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -123,9 +123,8 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { if (determinaton.collection != null) { determinaton.collection.models.map( (other: SpecifyResource) => { - if (other.cid !== determinaton.cid) { + if (other.cid !== determinaton.cid) other.set('isCurrent', false); - } } ); } @@ -256,9 +255,9 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { ): void => updateLoanPrep(collection), customInit: (resource: SpecifyResource): void => { - resource.get('quantityReturned') == null && + resource.get('quantityReturned') === null && resource.set('quantityReturned', 0); - resource.get('quantityResolved') == null && + resource.get('quantityResolved') === null && resource.set('quantityResolved', 0); }, fieldChecks: { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 401e319fa52..c754a49479b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -114,7 +114,10 @@ export class BusinessRuleManager { } private async checkUnique(fieldName: string): Promise { - const toOneFields = + const toOneFields: + | RA + | null[] + | RA<{ field: string; otherFields: string[] }> = this.rules?.uniqueIn !== undefined ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? [] diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 1127163d6e2..c4b9a4fe5f9 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -31,8 +31,6 @@ export type SpecifyResource = { readonly saveBlockers?: Readonly>; readonly parent?: SpecifyResource; readonly noBusinessRules: boolean; - readonly _fetch?: unknown; - readonly _save?: unknown; readonly changed?: { [FIELD_NAME in TableFields]?: string | number }; readonly collection: Collection; readonly businessRuleManager?: BusinessRuleManager; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index 00354bab710..39e2c0ba668 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -218,11 +218,6 @@ "collection" ] }, - "Preparation":{ - "barcode":[ - "collectionmemberid" - ] - }, "PrepType":{ "name":[ "collection" From 3881441563171d367625547bfe75459dd3762dc0 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 14 Mar 2023 11:00:04 -0500 Subject: [PATCH 23/50] Fix LoanReturnPreparation bugs --- .../components/DataModel/businessRuleDefs.ts | 125 +++++++------- .../DataModel/interactionBusinessRules.ts | 154 +++++++++++------- 2 files changed, 165 insertions(+), 114 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 4944c9a3f96..18b1bec9156 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -4,7 +4,8 @@ import { checkPrepAvailability, getTotalLoaned, getTotalResolved, - interactionCache, + getTotalReturned, + previousLoanPreparations, updateLoanPrep, } from './interactionBusinessRules'; import { SpecifyResource } from './legacyTypes'; @@ -242,6 +243,10 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, }, LoanPreparation: { + customInit: (resource: SpecifyResource): void => { + if (!resource.isNew()) + resource.rgetCollection('loanReturnPreparations').then(updateLoanPrep); + }, fieldChecks: { quantity: (iprep: SpecifyResource): void => { checkPrepAvailability(iprep); @@ -255,74 +260,82 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { ): void => updateLoanPrep(collection), customInit: (resource: SpecifyResource): void => { - resource.get('quantityReturned') === null && - resource.set('quantityReturned', 0); - resource.get('quantityResolved') === null && - resource.set('quantityResolved', 0); + const returned = resource.get('quantityReturned'); + const resolved = resource.get('quantityResolved'); + if (returned === undefined) resource.set('quantityReturned', 0); + if (resolved === undefined) resource.set('quantityResolved', 0); + if (!resource.isNew()) { + previousLoanPreparations.previousReturned[resource.cid] = + Number(returned); + previousLoanPreparations.previousResolved[resource.cid] = + Number(resolved); + } + updateLoanPrep(resource.collection); }, fieldChecks: { quantityreturned: ( loanReturnPrep: SpecifyResource ): void => { - const returned = loanReturnPrep.get('quantityReturned'); - const previousReturned = interactionCache().previousReturned[ - Number(loanReturnPrep.cid) - ] - ? interactionCache().previousReturned[Number(loanReturnPrep.cid)] - : 0; - if (returned !== null && returned != previousReturned) { - const delta = returned - previousReturned; - let resolved = loanReturnPrep.get('quantityResolved'); - const totalLoaned = getTotalLoaned(loanReturnPrep); - const totalResolved = getTotalResolved(loanReturnPrep); - const max = - typeof totalLoaned === 'number' && typeof totalResolved === 'number' - ? totalLoaned - totalResolved - : 0; - if (resolved !== null && delta + resolved > max) { - loanReturnPrep.set('quantityReturned', previousReturned); + const returned = Number(loanReturnPrep.get('quantityReturned'))!; + const previousReturned = + previousLoanPreparations.previousReturned[loanReturnPrep.cid] ?? 0; + const previousResolved = + previousLoanPreparations.previousResolved[loanReturnPrep.cid] ?? 0; + + const totalLoaned = getTotalLoaned(loanReturnPrep)!; + + const totalResolved = getTotalResolved(loanReturnPrep)!; + const available = totalLoaned - totalResolved; + + if (returned != previousReturned) { + if (returned === available && previousReturned - returned === 1) { + } else if (returned < 0 || previousReturned < 0) { + loanReturnPrep.set('quantityReturned', 0); } else { - resolved = loanReturnPrep.get('quantityResolved')! + delta; - interactionCache().previousResolved[Number(loanReturnPrep.cid)] = - resolved; - loanReturnPrep.set('quantityResolved', resolved); + const changeInReturn = returned - previousReturned; + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + changeInReturn + previousResolved; + loanReturnPrep.set( + 'quantityResolved', + changeInReturn + previousResolved + ); } - interactionCache().previousReturned[Number(loanReturnPrep.cid)] = - loanReturnPrep.get('quantityReturned'); - updateLoanPrep(loanReturnPrep.collection); } + + if (returned > totalLoaned) + loanReturnPrep.set('quantityReturned', totalLoaned); + + const returnedLeft = totalLoaned - getTotalReturned(loanReturnPrep)!; + if (returned > returnedLeft) + loanReturnPrep.set('quantityReturned', returnedLeft); + + if (previousResolved < returned) { + loanReturnPrep.set('quantityResolved', returned); + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + returned; + } + + previousLoanPreparations.previousReturned[loanReturnPrep.cid] = + returned; + updateLoanPrep(loanReturnPrep.collection); }, quantityresolved: ( loanReturnPrep: SpecifyResource ): void => { - const resolved = loanReturnPrep.get('quantityResolved'); - const previousResolved = interactionCache().previousResolved[ - Number(loanReturnPrep.cid) - ] - ? interactionCache().previousResolved[Number(loanReturnPrep.cid)] - : 0; - if (resolved != previousResolved) { - const returned = loanReturnPrep.get('quantityReturned'); - const totalLoaned = getTotalLoaned(loanReturnPrep); - const totalResolved = getTotalResolved(loanReturnPrep); - const max = - typeof totalLoaned === 'number' && typeof totalResolved === 'number' - ? totalLoaned - totalResolved - : 0; - if (resolved !== null && returned !== null) { - if (resolved > max) { - loanReturnPrep.set('quantityResolved', previousResolved); - } - if (resolved < returned) { - interactionCache().previousReturned[Number(loanReturnPrep.cid)] = - resolved; - loanReturnPrep.set('quantityReturned', resolved); - } - } - interactionCache().previousResolved[Number(loanReturnPrep.cid)] = - loanReturnPrep.get('quantityResolved'); - updateLoanPrep(loanReturnPrep.collection); + const resolved = Number(loanReturnPrep.get('quantityResolved')); + + const totalLoaned = getTotalLoaned(loanReturnPrep)!; + const totalResolved = getTotalResolved(loanReturnPrep)!; + const available = totalLoaned - totalResolved; + if (resolved > available) { + loanReturnPrep.set('quantityResolved', available); } + + if (resolved < 0) loanReturnPrep.set('quantityResolved', 0); + + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + resolved; + updateLoanPrep(loanReturnPrep.collection); }, }, }, diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts index 30ca1f2e8e7..edbbd08bf8c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -1,32 +1,85 @@ -import { formsText } from '../../localization/forms'; import { getPrepAvailability } from '../../utils/ajax/specifyApi'; -import { f } from '../../utils/functools'; import { AnyInteractionPreparation } from './helperTypes'; import { SpecifyResource } from './legacyTypes'; import { fetchResource, idFromUrl } from './resource'; import { Collection } from './specifyModel'; import { LoanPreparation, LoanReturnPreparation } from './types'; -type InteractionBusinessRules = { - previousReturned: { [prepCid: number]: number }; - previousResolved: { [prepCid: number]: number }; +type PreviousLoanReturnPreparations = { + previousReturned: { + [cid: string]: number; + }; + previousResolved: { + [cid: string]: number; + }; }; -export var interactionCache = f.store( - (): InteractionBusinessRules => ({ - previousReturned: {}, - previousResolved: {}, - }) -); +/** + * This object is used to maintain an 'asychronous' count of previousReturned + * and previousResolved for LoanReturnPreparation. + * The internal _previous_attributes can not be used here as that would cause an infinite + * recursion chain + * + * For uses of this object, see the LoanReturnPreparation business rules in + * businessRuleDefs.ts + */ +export const previousLoanPreparations: PreviousLoanReturnPreparations = { + previousReturned: {}, + previousResolved: {}, +}; + +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantity + */ export const getTotalLoaned = ( loanReturnPrep: SpecifyResource ): number | undefined => { - return loanReturnPrep.collection + return loanReturnPrep.collection !== undefined ? loanReturnPrep.collection.related?.get('quantity') : undefined; }; +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantityReturned + */ +export const getTotalReturned = ( + loanReturnPrep: SpecifyResource +) => { + return loanReturnPrep.collection !== null + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + const returned = loanPrep.get('quantityReturned'); + return loanPrep.cid != loanReturnPrep.cid + ? sum + (typeof returned === 'number' ? returned : 0) + : sum; + }, 0) + : loanReturnPrep.get('quantityReturned'); +}; + +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantityResolved + */ +export const getTotalResolved = ( + loanReturnPrep: SpecifyResource +) => { + return loanReturnPrep.collection !== null + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + const resolved = loanPrep.get('quantityResolved'); + return loanPrep.cid != loanReturnPrep.cid + ? sum + (typeof resolved === 'number' ? resolved : 0) + : sum; + }, 0) + : loanReturnPrep.get('quantityResolved'); +}; + +/** + * Given a collection of LoanReturnPreparations, iterate through the + * collection and store the sum all of the quantityReturned and quantityResolved + * into an object + * + * Then, update the related Loan Preparation and set its quantityReturned and + * quantityResolved to the summed object values + */ export const updateLoanPrep = ( collection: Collection ) => { @@ -38,59 +91,46 @@ export const updateLoanPrep = ( (memo: { returned: number; resolved: number }, loanReturnPrep) => { const returned = loanReturnPrep.get('quantityReturned'); const resolved = loanReturnPrep.get('quantityResolved'); - memo.returned += typeof returned === 'number' ? returned : 0; - memo.resolved += typeof resolved === 'number' ? resolved : 0; + memo.returned += + typeof returned === 'number' || typeof returned === 'string' + ? Number(returned) + : 0; + memo.resolved += + typeof resolved === 'number' || typeof resolved === 'string' + ? Number(resolved) + : 0; return memo; }, { returned: 0, resolved: 0 } ); - const loanPrep: SpecifyResource = - collection.related as SpecifyResource; + const loanPrep = collection.related as SpecifyResource; loanPrep.set('quantityReturned', sums.returned); loanPrep.set('quantityResolved', sums.resolved); } }; -export const getTotalResolved = ( - loanReturnPrep: SpecifyResource -) => { - return loanReturnPrep.collection - ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { - const resolved = loanPrep.get('quantityResolved'); - return loanPrep.cid != loanReturnPrep.cid - ? sum + (typeof resolved === 'number' ? resolved : 0) - : sum; - }, 0) - : loanReturnPrep.get('quantityResolved'); -}; - -const updatePrepBlockers = ( +/** + * Check to enure an Interaction Preparation's quantity is greater + * than or equal to zero and less than preparation's count. + * If the interactionPrep's quantity exceeds this range, set it + * to the closest maxiumum of the range + */ +const validateInteractionPrepQuantity = ( interactionPrep: SpecifyResource -): Promise => { +) => { const prepUri = interactionPrep.get('preparation') ?? ''; const prepId = idFromUrl(prepUri); - return prepId === undefined + prepId === undefined ? Promise.resolve() - : fetchResource('Preparation', prepId) - .then((preparation) => { - const prepQuanity = interactionPrep.get('quantity'); - return typeof preparation.countAmt === 'number' && - typeof prepQuanity === 'number' - ? preparation.countAmt >= prepQuanity - : false; - }) - .then((isValid) => { - if (!isValid) { - if (interactionPrep.saveBlockers?.blockers) - interactionPrep.saveBlockers?.add( - 'parseError-quantity', - 'quantity', - formsText.invalidValue() - ); - } else { - interactionPrep.saveBlockers?.remove('parseError-quantity'); - } - }); + : fetchResource('Preparation', prepId).then((preparation) => { + const prepQuanity = interactionPrep.get('quantity'); + + if (typeof preparation.countAmt === 'number') { + if (Number(prepQuanity) >= preparation.countAmt) + interactionPrep.set('quantity', preparation.countAmt); + if (Number(prepQuanity) < 0) interactionPrep.set('quantity', 0); + } + }); }; export const checkPrepAvailability = ( @@ -102,19 +142,17 @@ export const checkPrepAvailability = ( ) { const prepUri = interactionPrep.get('preparation'); const prepId = typeof prepUri === 'string' ? idFromUrl(prepUri) : undefined; - updatePrepBlockers(interactionPrep); + validateInteractionPrepQuantity(interactionPrep); const interactionId = interactionPrep.isNew() ? undefined : interactionPrep.get('id'); - const interactionModelName = interactionPrep.isNew() - ? undefined - : interactionPrep.specifyModel.name; + const interactionModelName = interactionPrep.specifyModel.name; if (prepId !== undefined) - getPrepAvailability(prepId, interactionId, interactionModelName!).then( + getPrepAvailability(prepId, interactionId, interactionModelName).then( (available) => { const quantity = interactionPrep.get('quantity'); if ( - typeof available != 'undefined' && + available != 'null' && typeof quantity === 'number' && Number(available[0]) < quantity ) { From f413f6927f5c37cea9fdb9ad93b558e6de175294 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 14 Mar 2023 15:18:42 -0500 Subject: [PATCH 24/50] Narrow type of fieldName in checkField(...) to SCHEMA['fields'] --- .../lib/components/DataModel/businessRules.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index c754a49479b..042155ab81c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -62,13 +62,13 @@ export class BusinessRuleManager { this.resource.on('remove', this.removed, this); } - public checkField(fieldName: string) { + public checkField(fieldName: keyof SCHEMA['fields']) { const thisCheck: ResolvablePromise = flippedPromise(); this.addPromise(thisCheck); - this.fieldChangePromises[fieldName] !== undefined && - this.fieldChangePromises[fieldName].resolve('superseded'); - this.fieldChangePromises[fieldName] = thisCheck; + this.fieldChangePromises[fieldName as string] !== undefined && + this.fieldChangePromises[fieldName as string].resolve('superseded'); + this.fieldChangePromises[fieldName as string] = thisCheck; const checks = [ this.invokeRule('fieldChecks', fieldName, [this.resource]), @@ -77,12 +77,15 @@ export class BusinessRuleManager { if (isTreeResource(this.resource as SpecifyResource)) checks.push( - treeBusinessRules(this.resource as SpecifyResource, fieldName) + treeBusinessRules( + this.resource as SpecifyResource, + fieldName as string + ) ); Promise.all(checks) .then((results) => { - return thisCheck === this.fieldChangePromises[fieldName] + return thisCheck === this.fieldChangePromises[fieldName as string] ? this.processCheckFieldResults(fieldName, results) : undefined; }) @@ -92,7 +95,7 @@ export class BusinessRuleManager { } private processCheckFieldResults( - fieldName: string, + fieldName: keyof SCHEMA['fields'], results: RA ): Promise<(void | null)[]> { return Promise.all( @@ -104,7 +107,11 @@ export class BusinessRuleManager { return null; } if (result.valid === false) { - this.resource.saveBlockers!.add(result.key, fieldName, result.reason); + this.resource.saveBlockers!.add( + result.key, + fieldName as string, + result.reason + ); } else { this.resource.saveBlockers!.remove(result.key); return typeof result.action === 'function' ? result.action() : null; @@ -113,7 +120,9 @@ export class BusinessRuleManager { ); } - private async checkUnique(fieldName: string): Promise { + private async checkUnique( + fieldName: keyof SCHEMA['fields'] + ): Promise { const toOneFields: | RA | null[] @@ -126,7 +135,7 @@ export class BusinessRuleManager { const results: RA>> = toOneFields.map( (uniqueRule) => { let field = uniqueRule; - let fieldNames: string[] | null = [fieldName]; + let fieldNames: string[] | null = [fieldName as string]; if (uniqueRule === null) { fieldNames = null; } else if (typeof uniqueRule != 'string') { @@ -144,7 +153,7 @@ export class BusinessRuleManager { .filter((result) => result !== undefined) .forEach((duplicate: SpecifyResource | undefined) => { if (duplicate === undefined) return; - const event = duplicate.cid + ':' + fieldName; + const event = duplicate.cid + ':' + (fieldName as string); if (!this.watchers[event]) { this.watchers[event] = () => duplicate.on('change remove', () => this.checkField(fieldName)); @@ -154,9 +163,9 @@ export class BusinessRuleManager { return Promise.all(results).then((results) => { const invalids = results.filter((result) => !result.valid); return invalids.length < 1 - ? { key: 'br-uniqueness-' + fieldName, valid: true } + ? { key: 'br-uniqueness-' + (fieldName as string), valid: true } : { - key: 'br-uniqueness-' + fieldName, + key: 'br-uniqueness-' + (fieldName as string), valid: false, reason: formatConjunction( invalids.map( @@ -345,7 +354,7 @@ export class BusinessRuleManager { private invokeRule( ruleName: keyof BusinessRuleDefs, - fieldName: string | undefined, + fieldName: keyof SCHEMA['fields'] | undefined, args: RA ): Promise { if (this.rules === undefined) { From 72521568a6ca01f3af6a2f6971c0fb54fa895b1c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Mar 2023 09:06:52 -0500 Subject: [PATCH 25/50] Add uniqueness rule tests --- .../DataModel/__tests__/businessRules.test.ts | 273 ++++++++++-------- .../lib/components/DataModel/businessRules.ts | 12 +- 2 files changed, 164 insertions(+), 121 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index acc658e6919..d6ecf4c994e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -1,116 +1,157 @@ -import { overrideAjax } from '../../../tests/ajax'; -import { mockTime, requireContext } from '../../../tests/helpers'; -import { businessRuleDefs } from '../businessRuleDefs'; -import { SerializedModel } from '../helperTypes'; -import { getResourceApiUrl } from '../resource'; -import { schema } from '../schema'; -import { Determination } from '../types'; - -mockTime(); -requireContext(); - -test('uniqueness rules assigned correctly', async () => { - const accessionAgentUniquenessRules = { - role: [ - { - field: 'accession', - otherFields: ['agent'], - }, - { - field: 'repositoryagreement', - otherFields: ['agent'], - }, - ], - agent: [ - { - field: 'accession', - otherFields: ['role'], - }, - { - field: 'repositoryagreement', - otherFields: ['role'], - }, - ], - }; - expect(businessRuleDefs.AccessionAgent?.uniqueIn).toBe( - accessionAgentUniquenessRules - ); -}); - -const determinationId = 321; -const determinationUrl = getResourceApiUrl('Determination', determinationId); -const determinationResponse: Partial> = { - resource_uri: determinationUrl, - id: determinationId, -}; - -const collectionObjectId = 220; -const collectionObjectUrl = getResourceApiUrl( - 'CollectionObject', - collectionObjectId -); -const collectionObjectResponse = { - id: collectionObjectId, - resource_uri: collectionObjectUrl, - catalognumber: '000022002', - collection: getResourceApiUrl('Collection', 4), - determinations: [determinationResponse], -}; - -overrideAjax(collectionObjectUrl, collectionObjectResponse); -overrideAjax(determinationUrl, determinationResponse); - -test('collectionObject customInit', () => { - const resource = new schema.models.CollectionObject.Resource({ - id: collectionObjectId, - }); - expect(resource.get('collectingEvent')).toBeDefined(); -}); - -describe('determination business rules', () => { - const resource = new schema.models.CollectionObject.Resource({ - id: collectionObjectId, - }); - const determination = new schema.models.Determination.Resource({ - id: determinationId, - }); - test('determination customInit', async () => { - await determination.fetch(); - expect(determination.get('isCurrent')).toBe(true); - }); - test('only one determination isCurrent', async () => { - await resource.rgetCollection('determinations').then((collection) => { - collection.add(new schema.models.Determination.Resource()); - }); - expect(determination.get('isCurrent')).toBe(false); - }); - test('determination taxon field check', async () => { - const taxonId = 19345; - const taxonUrl = getResourceApiUrl('Taxon', taxonId); - const taxonResponse = { - resource_uri: getResourceApiUrl('Taxon', taxonUrl), - id: taxonId, - name: 'melas', - fullName: 'Ameiurus melas', - }; - overrideAjax(taxonUrl, taxonResponse); - determination.set( - 'taxon', - new schema.models.Taxon.Resource({ - id: taxonId, - }) - ); - expect(determination.get('preferredTaxon')).toBe(taxonUrl); - }); -}); - -test('dnaSequence genesequence fieldCheck', async () => { - const dnaSequence = new schema.models.DNASequence.Resource({ - id: 1, - }); - dnaSequence.set('geneSequence', 'cat123gaaz'); - - expect(dnaSequence.get('totalResidues')).toBe(10); - expect(dnaSequence.get('compA')).toBe(3); - expect(dnaSequence.get('ambiguousResidues')).toBe(4); -}); +import { overrideAjax } from '../../../tests/ajax'; +import { mockTime, requireContext } from '../../../tests/helpers'; +import { businessRuleDefs } from '../businessRuleDefs'; +import { SerializedModel } from '../helperTypes'; +import { getResourceApiUrl } from '../resource'; +import { schema } from '../schema'; +import { Determination } from '../types'; + +mockTime(); +requireContext(); + +test('uniqueness rules assigned correctly', async () => { + const accessionAgentUniquenessRules = { + role: [ + { + field: 'accession', + otherFields: ['agent'], + }, + { + field: 'repositoryagreement', + otherFields: ['agent'], + }, + ], + agent: [ + { + field: 'accession', + otherFields: ['role'], + }, + { + field: 'repositoryagreement', + otherFields: ['role'], + }, + ], + }; + expect(businessRuleDefs.AccessionAgent?.uniqueIn).toBe( + accessionAgentUniquenessRules + ); +}); + +const determinationId = 321; +const determinationUrl = getResourceApiUrl('Determination', determinationId); +const determinationResponse: Partial> = { + resource_uri: determinationUrl, + id: determinationId, +}; + +const collectionObjectId = 220; +const collectionObjectUrl = getResourceApiUrl( + 'CollectionObject', + collectionObjectId +); +const collectionObjectResponse = { + id: collectionObjectId, + resource_uri: collectionObjectUrl, + catalognumber: '000022002', + collection: getResourceApiUrl('Collection', 4), + determinations: [determinationResponse], +}; + +overrideAjax(collectionObjectUrl, collectionObjectResponse); +overrideAjax(determinationUrl, determinationResponse); + +test('collectionObject customInit', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + await resource.fetch(); + expect(resource.get('collectingEvent')).toBeDefined(); + resource.save(); +}); + +describe('determination business rules', () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); + test('determination customInit', async () => { + await determination.fetch(); + expect(determination.get('isCurrent')).toBe(true); + }); + test('only one determination isCurrent', async () => { + await resource.rgetCollection('determinations').then((collection) => { + collection.add(new schema.models.Determination.Resource()); + }); + expect(determination.get('isCurrent')).toBe(false); + }); + test('determination taxon field check', async () => { + const taxonId = 19345; + const taxonUrl = getResourceApiUrl('Taxon', taxonId); + const taxonResponse = { + resource_uri: getResourceApiUrl('Taxon', taxonUrl), + id: taxonId, + name: 'melas', + fullName: 'Ameiurus melas', + }; + overrideAjax(taxonUrl, taxonResponse); + determination.set( + 'taxon', + new schema.models.Taxon.Resource({ + id: taxonId, + }) + ); + expect(determination.get('preferredTaxon')).toBe(taxonUrl); + }); +}); + +test('dnaSequence genesequence fieldCheck', async () => { + const dnaSequence = new schema.models.DNASequence.Resource({ + id: 1, + }); + dnaSequence.set('geneSequence', 'cat123gaaz'); + + expect(dnaSequence.get('totalResidues')).toBe(10); + expect(dnaSequence.get('compA')).toBe(3); + expect(dnaSequence.get('ambiguousResidues')).toBe(4); +}); + +test('global uniquenessRule', async () => { + const testPermit = new schema.models.Permit.Resource({ + id: 1, + permitNumber: '20', + }); + await testPermit.save(); + const duplicatePermit = new schema.models.Permit.Resource({ + id: 2, + permitNumber: '20', + }); + expect( + duplicatePermit + .fetch() + .then((permit) => permit.businessRuleManager?.checkField('permitNumber')) + ).resolves.toBe({ + key: 'br-uniqueness-permitnumber', + valid: false, + reason: 'Value must be unique to Database', + }); +}); + +test('scoped uniqueness rule', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: 221, + catalogNumber: '000022002', + }); + expect( + resource + .fetch() + .then((collectionObject) => + collectionObject.businessRuleManager?.checkField('catalogNumber') + ) + ).resolves.toBe({ + key: 'br-uniqueness-catalognumber', + valid: false, + reason: 'Value must be unique to Collection', + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 042155ab81c..cb749f2a699 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -62,7 +62,11 @@ export class BusinessRuleManager { this.resource.on('remove', this.removed, this); } - public checkField(fieldName: keyof SCHEMA['fields']) { + public async checkField( + fieldName: keyof SCHEMA['fields'] + ): Promise { + fieldName = + typeof fieldName === 'string' ? fieldName.toLowerCase() : fieldName; const thisCheck: ResolvablePromise = flippedPromise(); this.addPromise(thisCheck); @@ -83,15 +87,13 @@ export class BusinessRuleManager { ) ); - Promise.all(checks) + return Promise.all(checks) .then((results) => { return thisCheck === this.fieldChangePromises[fieldName as string] ? this.processCheckFieldResults(fieldName, results) : undefined; }) - .then(() => { - thisCheck.resolve('finished'); - }); + .then(() => thisCheck.resolve('finished')); } private processCheckFieldResults( From c18fd806f11fc7572807c8f7af4d751095b1643c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 16 Mar 2023 09:32:12 -0500 Subject: [PATCH 26/50] Make field names lowercase in rule definitions --- .../lib/components/DataModel/businessRuleDefs.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 18b1bec9156..15ad27cc0f3 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -33,7 +33,7 @@ export type BusinessRuleDefs = { readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { - [FIELD_NAME in TableFields as Lowercase]?: ( + [FIELD_NAME in TableFields]?: ( resource: SpecifyResource ) => Promise | void; }; @@ -59,7 +59,7 @@ type MappedBusinessRuleDefs = { export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { BorrowMaterial: { fieldChecks: { - quantityreturned: ( + quantityReturned: ( borrowMaterial: SpecifyResource ): void => { const returned = borrowMaterial.get('quantityReturned'); @@ -79,7 +79,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { newVal && borrowMaterial.set('quantityReturned', newVal); }, - quantityresolved: ( + quantityResolved: ( borrowMaterial: SpecifyResource ): void => { const resolved = borrowMaterial.get('quantityResolved'); @@ -165,7 +165,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }; }); }, - iscurrent: ( + isCurrent: ( determination: SpecifyResource ): Promise | void => { if ( @@ -194,7 +194,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, DNASequence: { fieldChecks: { - genesequence: (dnaSequence: SpecifyResource): void => { + geneSequence: (dnaSequence: SpecifyResource): void => { const current = dnaSequence.get('geneSequence'); if (current === null) return; const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; @@ -273,7 +273,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { updateLoanPrep(resource.collection); }, fieldChecks: { - quantityreturned: ( + quantityReturned: ( loanReturnPrep: SpecifyResource ): void => { const returned = Number(loanReturnPrep.get('quantityReturned'))!; @@ -319,7 +319,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { returned; updateLoanPrep(loanReturnPrep.collection); }, - quantityresolved: ( + quantityResolved: ( loanReturnPrep: SpecifyResource ): void => { const resolved = Number(loanReturnPrep.get('quantityResolved')); From d9513f6d1053cc0f9702c4f7068560a834104d5c Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 31 Mar 2023 16:29:27 -0500 Subject: [PATCH 27/50] Check prep availabiliy for Disposal when DisposalPrep quantity changes --- .../js_src/lib/components/DataModel/businessRuleDefs.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 15ad27cc0f3..b043d64938a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -16,6 +16,7 @@ import { BorrowMaterial, CollectionObject, Determination, + DisposalPreparation, DNASequence, GiftPreparation, LoanPreparation, @@ -192,6 +193,13 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, }, }, + DisposalPreparation: { + fieldChecks: { + quantity: (disposalPrep: SpecifyResource): void => { + checkPrepAvailability(disposalPrep); + }, + }, + }, DNASequence: { fieldChecks: { geneSequence: (dnaSequence: SpecifyResource): void => { From f8083d0f7d74126e6e48ab4075dc4c62acacb3c3 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 4 Apr 2023 11:33:22 -0500 Subject: [PATCH 28/50] Revert fieldnames to lowercase in business rule definitions --- .../lib/components/DataModel/businessRuleDefs.ts | 14 +++++++------- .../lib/components/DataModel/businessRules.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index b043d64938a..ee03352eba4 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -34,7 +34,7 @@ export type BusinessRuleDefs = { readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { - [FIELD_NAME in TableFields]?: ( + [FIELD_NAME in TableFields as Lowercase]?: ( resource: SpecifyResource ) => Promise | void; }; @@ -60,7 +60,7 @@ type MappedBusinessRuleDefs = { export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { BorrowMaterial: { fieldChecks: { - quantityReturned: ( + quantityreturned: ( borrowMaterial: SpecifyResource ): void => { const returned = borrowMaterial.get('quantityReturned'); @@ -80,7 +80,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { newVal && borrowMaterial.set('quantityReturned', newVal); }, - quantityResolved: ( + quantityresolved: ( borrowMaterial: SpecifyResource ): void => { const resolved = borrowMaterial.get('quantityResolved'); @@ -166,7 +166,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }; }); }, - isCurrent: ( + iscurrent: ( determination: SpecifyResource ): Promise | void => { if ( @@ -202,7 +202,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, DNASequence: { fieldChecks: { - geneSequence: (dnaSequence: SpecifyResource): void => { + genesequence: (dnaSequence: SpecifyResource): void => { const current = dnaSequence.get('geneSequence'); if (current === null) return; const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; @@ -281,7 +281,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { updateLoanPrep(resource.collection); }, fieldChecks: { - quantityReturned: ( + quantityreturned: ( loanReturnPrep: SpecifyResource ): void => { const returned = Number(loanReturnPrep.get('quantityReturned'))!; @@ -327,7 +327,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { returned; updateLoanPrep(loanReturnPrep.collection); }, - quantityResolved: ( + quantityresolved: ( loanReturnPrep: SpecifyResource ): void => { const resolved = Number(loanReturnPrep.get('quantityResolved')); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index cb749f2a699..29a39702f84 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -354,7 +354,7 @@ export class BusinessRuleManager { } } - private invokeRule( + private async invokeRule( ruleName: keyof BusinessRuleDefs, fieldName: keyof SCHEMA['fields'] | undefined, args: RA From 25997e2cf1af2524250d09af90ebb797fa84c9fb Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 12 Apr 2023 16:48:20 -0500 Subject: [PATCH 29/50] Fix remaining Typescript errors --- .../components/DataModel/businessRuleDefs.ts | 30 +++++++++------ .../lib/components/DataModel/businessRules.ts | 37 ++++++++++++------- .../lib/components/DataModel/helpers.ts | 8 +++- .../lib/components/DataModel/legacyTypes.ts | 4 +- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index ee03352eba4..0e7363ee9db 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -49,7 +49,7 @@ type UniquenessRules = { export type UniquenessRule = { [FIELD_NAME in TableFields as Lowercase]?: | RA - | null[] + | RA | RA<{ field: string; otherFields: string[] }>; }; @@ -349,16 +349,22 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, }; +// From this code, Typescript believes that a businessRuleDefs uniqueIn can be from any table +// For example, it believes the following is possible: +// BusinessRuleDefs & {uniqueIn: UniquenessRule | UniquenessRule | ...} +// @ts-expect-error export const businessRuleDefs: MappedBusinessRuleDefs = Object.fromEntries( - Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }).map( - (table: keyof Tables) => { - const ruleDefs = - nonUniqueBusinessRuleDefs[table] === undefined - ? { uniqueIn: uniqueRules[table] } - : Object.assign({}, nonUniqueBusinessRuleDefs[table], { - uniqueIn: uniqueRules[table], - }); - return [table, ruleDefs]; - } - ) + ( + Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }) as Array< + keyof Tables + > + ).map((table) => { + const ruleDefs = + nonUniqueBusinessRuleDefs[table] === undefined + ? { uniqueIn: uniqueRules[table] } + : Object.assign({}, nonUniqueBusinessRuleDefs[table], { + uniqueIn: uniqueRules[table], + }); + return [table, ruleDefs]; + }) ); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 29a39702f84..d133fe7c9c0 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -28,7 +28,7 @@ export class BusinessRuleManager { } private addPromise( - promise: Promise + promise: Promise ): void { this.pendingPromises = Promise.allSettled([ this.pendingPromises, @@ -98,8 +98,8 @@ export class BusinessRuleManager { private processCheckFieldResults( fieldName: keyof SCHEMA['fields'], - results: RA - ): Promise<(void | null)[]> { + results: RA + ): Promise> { return Promise.all( results.map((result) => { if (!result) return null; @@ -125,17 +125,19 @@ export class BusinessRuleManager { private async checkUnique( fieldName: keyof SCHEMA['fields'] ): Promise { - const toOneFields: - | RA - | null[] - | RA<{ field: string; otherFields: string[] }> = + const toOneFields = this.rules?.uniqueIn !== undefined ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? [] : []; + // Typescript thinks that map() does not exist on NonNullable> + // However, map() exists on every possible type of UniquenessRule + // @ts-expect-error const results: RA>> = toOneFields.map( - (uniqueRule) => { + ( + uniqueRule: string | null | { field: string; otherFields: string[] } + ) => { let field = uniqueRule; let fieldNames: string[] | null = [fieldName as string]; if (uniqueRule === null) { @@ -358,19 +360,26 @@ export class BusinessRuleManager { ruleName: keyof BusinessRuleDefs, fieldName: keyof SCHEMA['fields'] | undefined, args: RA - ): Promise { - if (this.rules === undefined) { + ): Promise { + if (this.rules === undefined || ruleName === 'uniqueIn') { return Promise.resolve(undefined); } let rule = this.rules[ruleName]; - if (rule === undefined) return Promise.resolve(undefined); - if (fieldName !== undefined) { + if ( + rule !== undefined && + ruleName === 'fieldChecks' && + fieldName !== undefined + ) rule = rule[fieldName as keyof typeof rule]; - } + if (rule === undefined) return Promise.resolve(undefined); - return Promise.resolve(rule.apply(this, args)); + // For some reason, Typescript still thinks that this.rules["fieldChecks"] is a valid rule + // thus rule.apply() would be invalid + // However, rule will never be this.rules["fieldChecks"] + // @ts-expect-error + return Promise.resolve(rule.apply(undefined, args)); } } diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts index e137e61f3ae..5c3f5773f36 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts @@ -116,7 +116,9 @@ export const toTable = ( resource: SpecifyResource, tableName: TABLE_NAME ): SpecifyResource | undefined => - resource.specifyModel.name === tableName ? resource : undefined; + resource.specifyModel.name === tableName + ? (resource as SpecifyResource) + : undefined; export const toResource = ( resource: SerializedResource, @@ -150,7 +152,9 @@ export const toTables = ( resource: SpecifyResource, tableNames: RA ): SpecifyResource | undefined => - f.includes(tableNames, resource.specifyModel.name) ? resource : undefined; + f.includes(tableNames, resource.specifyModel.name) + ? (resource as SpecifyResource) + : undefined; export const deserializeResource = ( serializedResource: SerializedModel | SerializedResource diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index c4b9a4fe5f9..8041b06f954 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -31,7 +31,9 @@ export type SpecifyResource = { readonly saveBlockers?: Readonly>; readonly parent?: SpecifyResource; readonly noBusinessRules: boolean; - readonly changed?: { [FIELD_NAME in TableFields]?: string | number }; + readonly changed?: { + [FIELD_NAME in TableFields]?: string | number; + }; readonly collection: Collection; readonly businessRuleManager?: BusinessRuleManager; /* From 4fdd0e14a96a4768ac92140830cdc12b41e01290 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 14 Apr 2023 13:28:15 -0500 Subject: [PATCH 30/50] Add static division for tests --- .../ajax/static/api/specify/division/2.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json new file mode 100644 index 00000000000..1ef56faa1ef --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json @@ -0,0 +1,23 @@ +{ + "id": 2, + "abbrev": "FISH", + "altname": "KU Biodiversity Institute Ichthyology Division", + "description": null, + "discipline": "fish", + "iconuri": null, + "name": "Ichthyology", + "regnumber": "1344636812.33", + "remarks": null, + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2019-12-18T09:17:04", + "uri": null, + "version": 2, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "institution": "/api/specify/institution/1/", + "modifiedbyagent": "/api/specify/agent/1514/", + "members": "/api/specify/agent/?division=2", + "disciplines": "/api/specify/discipline/?division=2", + "resource_uri": "/api/specify/division/2/" + } + \ No newline at end of file From a056fec8e458b44489f1a05cd1079cb36427c3df Mon Sep 17 00:00:00 2001 From: melton-jason Date: Fri, 14 Apr 2023 14:50:26 -0500 Subject: [PATCH 31/50] Add overrideAjax calls to fix failing unit tests This is primarily to test the scope of the failing tests. The calls will most likely ned to be added in lib/tests/ajax/static if declaring the overrides in this way does not work --- .../DataModel/__tests__/businessRules.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index d6ecf4c994e..17394ac0182 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -9,6 +9,78 @@ import { Determination } from '../types'; mockTime(); requireContext(); +overrideAjax( + '/api/specify/discipline/?domainfilter=false&name=Ichthyology&division=2&offset=0', + { + objects: [ + { + id: 3, + ispaleocontextembedded: true, + name: 'Ichthyology', + paleocontextchildtable: 'collectionobject', + regnumber: '1344636812.54', + timestampcreated: '2012-08-09T12:23:29', + timestampmodified: '2012-08-09T12:23:29', + type: 'fish', + version: 11, + createdbyagent: '/api/specify/agent/1986/', + datatype: '/api/specify/datatype/1/', + division: '/api/specify/division/2/', + geographytreedef: '/api/specify/geographytreedef/1/', + geologictimeperiodtreedef: '/api/specify/geologictimeperiodtreedef/1/', + lithostrattreedef: '/api/specify/lithostrattreedef/1/', + modifiedbyagent: '/api/specify/agent/1987/', + taxontreedef: '/api/specify/taxontreedef/1/', + attributedefs: '/api/specify/attributedef/?discipline=3', + collections: '/api/specify/collection/?discipline=3', + spexportschemas: '/api/specify/spexportschema/?discipline=3', + splocalecontainers: '/api/specify/splocalecontainer/?discipline=3', + resource_uri: '/api/specify/discipline/3/', + }, + ], + meta: { + limit: 20, + offset: 0, + total_count: 1, + }, + } +); + +overrideAjax( + '/api/specify/division/?domainfilter=false&name=Ichthyology&institution=1&offset=0', + { + objects: [ + { + id: 2, + abbrev: 'FISH', + altname: 'KU Biodiversity Institute Ichthyology Division', + description: null, + discipline: 'fish', + iconuri: null, + name: 'Ichthyology', + regnumber: '1344636812.33', + remarks: null, + timestampcreated: '2012-08-09T12:23:29', + timestampmodified: '2019-12-18T09:17:04', + uri: null, + version: 2, + address: null, + createdbyagent: '/api/specify/agent/1986/', + institution: '/api/specify/institution/1/', + modifiedbyagent: '/api/specify/agent/1514/', + members: '/api/specify/agent/?division=2', + disciplines: '/api/specify/discipline/?division=2', + resource_uri: '/api/specify/division/2/', + }, + ], + meta: { + limit: 20, + offset: 0, + total_count: 1, + }, + } +); + test('uniqueness rules assigned correctly', async () => { const accessionAgentUniquenessRules = { role: [ From 243ee0b3f810b05c153aeac27d5fe161abdf2c24 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 17 Apr 2023 16:22:17 -0500 Subject: [PATCH 32/50] Modify getUniqueFields to follow new UniquenessRules format --- .../js_src/lib/components/DataModel/resource.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index 245ab64f28c..ed94d12d2b5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -16,7 +16,6 @@ import type { AnySchema, SerializedModel, SerializedResource, - TableFields, } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { getModel, schema } from './schema'; @@ -272,17 +271,6 @@ export const getFieldsToClone = (model: SpecifyModel): RA => ) .map(({ name }) => name); -// REFACTOR: move this into businessRuleDefs.ts -const businessRules = businessRuleDefs as { - readonly [TABLE_NAME in keyof Tables]?: { - readonly uniqueIn?: { - readonly [FIELD_NAME in Lowercase>]?: - | Lowercase - | unknown; - }; - }; -}; - const uniqueFields = [ 'guid', 'timestampCreated', @@ -293,10 +281,9 @@ const uniqueFields = [ export const getUniqueFields = (model: SpecifyModel): RA => f.unique([ - ...Object.entries(businessRules[model.name]?.uniqueIn ?? {}) + ...Object.entries(businessRuleDefs[model.name]?.uniqueIn ?? {}) .filter( ([_fieldName, uniquenessRules]) => - typeof uniquenessRules === 'string' && uniquenessRules in schema.domainLevelIds ) .map(([fieldName]) => model.strictGetField(fieldName).name), From 224544806c032ecdbb34bbbf01eec2200212f452 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 17 Apr 2023 16:26:55 -0500 Subject: [PATCH 33/50] Add static files to address failing tests --- .../DataModel/__tests__/businessRules.test.ts | 72 ------------------- ...&name=Ichthyology&division=2&offset=0.json | 33 +++++++++ ...me=Ichthyology&institution=1&offset=0.json | 31 ++++++++ 3 files changed, 64 insertions(+), 72 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json create mode 100644 specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 17394ac0182..d6ecf4c994e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -9,78 +9,6 @@ import { Determination } from '../types'; mockTime(); requireContext(); -overrideAjax( - '/api/specify/discipline/?domainfilter=false&name=Ichthyology&division=2&offset=0', - { - objects: [ - { - id: 3, - ispaleocontextembedded: true, - name: 'Ichthyology', - paleocontextchildtable: 'collectionobject', - regnumber: '1344636812.54', - timestampcreated: '2012-08-09T12:23:29', - timestampmodified: '2012-08-09T12:23:29', - type: 'fish', - version: 11, - createdbyagent: '/api/specify/agent/1986/', - datatype: '/api/specify/datatype/1/', - division: '/api/specify/division/2/', - geographytreedef: '/api/specify/geographytreedef/1/', - geologictimeperiodtreedef: '/api/specify/geologictimeperiodtreedef/1/', - lithostrattreedef: '/api/specify/lithostrattreedef/1/', - modifiedbyagent: '/api/specify/agent/1987/', - taxontreedef: '/api/specify/taxontreedef/1/', - attributedefs: '/api/specify/attributedef/?discipline=3', - collections: '/api/specify/collection/?discipline=3', - spexportschemas: '/api/specify/spexportschema/?discipline=3', - splocalecontainers: '/api/specify/splocalecontainer/?discipline=3', - resource_uri: '/api/specify/discipline/3/', - }, - ], - meta: { - limit: 20, - offset: 0, - total_count: 1, - }, - } -); - -overrideAjax( - '/api/specify/division/?domainfilter=false&name=Ichthyology&institution=1&offset=0', - { - objects: [ - { - id: 2, - abbrev: 'FISH', - altname: 'KU Biodiversity Institute Ichthyology Division', - description: null, - discipline: 'fish', - iconuri: null, - name: 'Ichthyology', - regnumber: '1344636812.33', - remarks: null, - timestampcreated: '2012-08-09T12:23:29', - timestampmodified: '2019-12-18T09:17:04', - uri: null, - version: 2, - address: null, - createdbyagent: '/api/specify/agent/1986/', - institution: '/api/specify/institution/1/', - modifiedbyagent: '/api/specify/agent/1514/', - members: '/api/specify/agent/?division=2', - disciplines: '/api/specify/discipline/?division=2', - resource_uri: '/api/specify/division/2/', - }, - ], - meta: { - limit: 20, - offset: 0, - total_count: 1, - }, - } -); - test('uniqueness rules assigned correctly', async () => { const accessionAgentUniquenessRules = { role: [ diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json new file mode 100644 index 00000000000..3161b8e2b98 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json @@ -0,0 +1,33 @@ +{ + "objects": [ + { + "id": 3, + "ispaleocontextembedded": true, + "name": "Ichthyology", + "paleocontextchildtable": "collectionobject", + "regnumber": "1344636812.54", + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2012-08-09T12:23:29", + "type": "fish", + "version": 11, + "createdbyagent": "/api/specify/agent/1986/", + "datatype": "/api/specify/datatype/1/", + "division": "/api/specify/division/2/", + "geographytreedef": "/api/specify/geographytreedef/1/", + "geologictimeperiodtreedef": "/api/specify/geologictimeperiodtreedef/1/", + "lithostrattreedef": "/api/specify/lithostrattreedef/1/", + "modifiedbyagent": "/api/specify/agent/1987/", + "taxontreedef": "/api/specify/taxontreedef/1/", + "attributedefs": "/api/specify/attributedef/?discipline=3", + "collections": "/api/specify/collection/?discipline=3", + "spexportschemas": "/api/specify/spexportschema/?discipline=3", + "splocalecontainers": "/api/specify/splocalecontainer/?discipline=3", + "resource_uri": "/api/specify/discipline/3/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } + } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json new file mode 100644 index 00000000000..617a6948725 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json @@ -0,0 +1,31 @@ +{ + "objects": [ + { + "id": 2, + "abbrev": "FISH", + "altname": "KU Biodiversity Institute Ichthyology Division", + "description": null, + "discipline": "fish", + "iconuri": null, + "name": "Ichthyology", + "regnumber": "1344636812.33", + "remarks": null, + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2019-12-18T09:17:04", + "uri": null, + "version": 2, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "institution": "/api/specify/institution/1/", + "modifiedbyagent": "/api/specify/agent/1514/", + "members": "/api/specify/agent/?division=2", + "disciplines": "/api/specify/discipline/?division=2", + "resource_uri": "/api/specify/division/2/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } + } \ No newline at end of file From e846ab1b9c2524257a0c570f85dce24d601037d2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 17 Apr 2023 16:39:32 -0500 Subject: [PATCH 34/50] Change resource tests to accomadate new uniqueness rules --- .../js_src/lib/components/DataModel/__tests__/resource.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts index 3e89a65e086..7019bdf503b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts @@ -251,6 +251,7 @@ describe('getUniqueFields', () => { test('CollectionObject', () => expect(getUniqueFields(schema.models.CollectionObject)).toEqual([ 'catalogNumber', + 'uniqueIdentifier', 'guid', 'collectionObjectAttachments', 'timestampCreated', @@ -259,6 +260,7 @@ describe('getUniqueFields', () => { ])); test('Locality', () => expect(getUniqueFields(schema.models.Locality)).toEqual([ + 'uniqueIdentifier', 'localityAttachments', 'guid', 'timestampCreated', From 2498fca75b87206c0fccb13de1865cf37b075e9e Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 17 Apr 2023 16:59:50 -0500 Subject: [PATCH 35/50] Add unique fields in fields to not clone test --- .../js_src/lib/components/DataModel/__tests__/resource.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts index 7019bdf503b..0b9679396a5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts @@ -295,6 +295,7 @@ test('getFieldsToNotClone', () => { 'guid', 'timestampCreated', 'totalCountAmt', + 'uniqueIdentifier', 'version', 'collectionObjectAttachments', 'currentDetermination', @@ -308,6 +309,7 @@ test('getFieldsToNotClone', () => { 'text1', 'timestampCreated', 'totalCountAmt', + 'uniqueIdentifier', 'version', 'collectionObjectAttachments', 'currentDetermination', From e23a1488473f83cf3b2291ad6f268e03841be2ed Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 18 Apr 2023 10:09:23 -0500 Subject: [PATCH 36/50] Fix CollectionObject not being defined for schema.models in business rule test --- .../DataModel/__tests__/businessRules.test.ts | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index d6ecf4c994e..3c2f69c944d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -60,98 +60,101 @@ const collectionObjectResponse = { overrideAjax(collectionObjectUrl, collectionObjectResponse); overrideAjax(determinationUrl, determinationResponse); -test('collectionObject customInit', async () => { +describe('business rules', () => { const resource = new schema.models.CollectionObject.Resource({ id: collectionObjectId, }); - await resource.fetch(); - expect(resource.get('collectingEvent')).toBeDefined(); - resource.save(); -}); - -describe('determination business rules', () => { - const resource = new schema.models.CollectionObject.Resource({ - id: collectionObjectId, + test('collectionObject customInit', async () => { + await resource.fetch(); + expect(resource.get('collectingEvent')).toBeDefined(); + resource.save(); }); - const determination = new schema.models.Determination.Resource({ - id: determinationId, - }); - test('determination customInit', async () => { - await determination.fetch(); - expect(determination.get('isCurrent')).toBe(true); - }); - test('only one determination isCurrent', async () => { - await resource.rgetCollection('determinations').then((collection) => { - collection.add(new schema.models.Determination.Resource()); + + test('determination business rules', () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, }); - expect(determination.get('isCurrent')).toBe(false); - }); - test('determination taxon field check', async () => { - const taxonId = 19345; - const taxonUrl = getResourceApiUrl('Taxon', taxonId); - const taxonResponse = { - resource_uri: getResourceApiUrl('Taxon', taxonUrl), - id: taxonId, - name: 'melas', - fullName: 'Ameiurus melas', - }; - overrideAjax(taxonUrl, taxonResponse); - determination.set( - 'taxon', - new schema.models.Taxon.Resource({ + test('determination customInit', async () => { + await determination.fetch(); + expect(determination.get('isCurrent')).toBe(true); + }); + test('only one determination isCurrent', async () => { + await resource.rgetCollection('determinations').then((collection) => { + collection.add(new schema.models.Determination.Resource()); + }); + expect(determination.get('isCurrent')).toBe(false); + }); + test('determination taxon field check', async () => { + const taxonId = 19345; + const taxonUrl = getResourceApiUrl('Taxon', taxonId); + const taxonResponse = { + resource_uri: getResourceApiUrl('Taxon', taxonUrl), id: taxonId, - }) - ); - expect(determination.get('preferredTaxon')).toBe(taxonUrl); - }); -}); - -test('dnaSequence genesequence fieldCheck', async () => { - const dnaSequence = new schema.models.DNASequence.Resource({ - id: 1, + name: 'melas', + fullName: 'Ameiurus melas', + }; + overrideAjax(taxonUrl, taxonResponse); + determination.set( + 'taxon', + new schema.models.Taxon.Resource({ + id: taxonId, + }) + ); + expect(determination.get('preferredTaxon')).toBe(taxonUrl); + }); }); - dnaSequence.set('geneSequence', 'cat123gaaz'); - expect(dnaSequence.get('totalResidues')).toBe(10); - expect(dnaSequence.get('compA')).toBe(3); - expect(dnaSequence.get('ambiguousResidues')).toBe(4); -}); + test('dnaSequence genesequence fieldCheck', async () => { + const dnaSequence = new schema.models.DNASequence.Resource({ + id: 1, + }); + dnaSequence.set('geneSequence', 'cat123gaaz'); -test('global uniquenessRule', async () => { - const testPermit = new schema.models.Permit.Resource({ - id: 1, - permitNumber: '20', - }); - await testPermit.save(); - const duplicatePermit = new schema.models.Permit.Resource({ - id: 2, - permitNumber: '20', - }); - expect( - duplicatePermit - .fetch() - .then((permit) => permit.businessRuleManager?.checkField('permitNumber')) - ).resolves.toBe({ - key: 'br-uniqueness-permitnumber', - valid: false, - reason: 'Value must be unique to Database', + expect(dnaSequence.get('totalResidues')).toBe(10); + expect(dnaSequence.get('compA')).toBe(3); + expect(dnaSequence.get('ambiguousResidues')).toBe(4); }); }); -test('scoped uniqueness rule', async () => { - const resource = new schema.models.CollectionObject.Resource({ - id: 221, - catalogNumber: '000022002', +describe('uniquenessRules', () => { + test('global uniquenessRule', async () => { + const testPermit = new schema.models.Permit.Resource({ + id: 1, + permitNumber: '20', + }); + await testPermit.save(); + const duplicatePermit = new schema.models.Permit.Resource({ + id: 2, + permitNumber: '20', + }); + expect( + duplicatePermit + .fetch() + .then((permit) => + permit.businessRuleManager?.checkField('permitNumber') + ) + ).resolves.toBe({ + key: 'br-uniqueness-permitnumber', + valid: false, + reason: 'Value must be unique to Database', + }); }); - expect( - resource - .fetch() - .then((collectionObject) => - collectionObject.businessRuleManager?.checkField('catalogNumber') - ) - ).resolves.toBe({ - key: 'br-uniqueness-catalognumber', - valid: false, - reason: 'Value must be unique to Collection', + + test('scoped uniqueness rule', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: 221, + catalogNumber: '000022002', + }); + expect( + resource + .fetch() + .then((collectionObject) => + collectionObject.businessRuleManager?.checkField('catalogNumber') + ) + ).resolves.toBe({ + key: 'br-uniqueness-catalognumber', + valid: false, + reason: 'Value must be unique to Collection', + }); }); }); From d1303691381ef52a383de18b5c67cffb87df9c5f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 18 Apr 2023 10:33:29 -0500 Subject: [PATCH 37/50] Add overrideAjax to all resources in business rule tests --- .../DataModel/__tests__/businessRules.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 3c2f69c944d..71a2d6cec93 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -53,7 +53,7 @@ const collectionObjectResponse = { id: collectionObjectId, resource_uri: collectionObjectUrl, catalognumber: '000022002', - collection: getResourceApiUrl('Collection', 4), + collection: getResourceApiUrl('CollectionObject', 4), determinations: [determinationResponse], }; @@ -118,13 +118,22 @@ describe('business rules', () => { describe('uniquenessRules', () => { test('global uniquenessRule', async () => { + const permitOneId = 1; + overrideAjax(getResourceApiUrl('Permit', permitOneId), { + resource_uri: getResourceApiUrl('Permit', permitOneId), + }); const testPermit = new schema.models.Permit.Resource({ - id: 1, + id: permitOneId, permitNumber: '20', }); await testPermit.save(); + + const permitTwoId = 2; + overrideAjax(getResourceApiUrl('Permit', permitTwoId), { + resource_uri: getResourceApiUrl('Permit', permitTwoId), + }); const duplicatePermit = new schema.models.Permit.Resource({ - id: 2, + id: permitTwoId, permitNumber: '20', }); expect( @@ -141,6 +150,10 @@ describe('uniquenessRules', () => { }); test('scoped uniqueness rule', async () => { + overrideAjax('CollectionObject', { + resource_uri: getResourceApiUrl('CollectionObject', 221), + catalogNumber: '000022002', + }); const resource = new schema.models.CollectionObject.Resource({ id: 221, catalogNumber: '000022002', From fb3389f70afa7346b0e4d4706275f5af10d827cd Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Wed, 19 Apr 2023 09:41:40 -0500 Subject: [PATCH 38/50] Remove redundant text-left classNames They became redundant because of fc00e2b34 --- .../frontend/js_src/lib/components/AppResources/Aside.tsx | 6 ++++-- .../js_src/lib/components/Statistics/StatsResult.tsx | 5 +---- specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx | 4 ++++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx index 2f37ecf836e..c8c31880b74 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx @@ -214,7 +214,7 @@ function TreeItem({ > handleFold( @@ -226,7 +226,9 @@ function TreeItem({ > {count}, + wrap: (count) => ( + {count} + ), }} string={commonText.jsxCountLine({ resource: label, diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx index 514b4b1add4..415661b091a 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx @@ -54,10 +54,7 @@ export function StatsResult({ ) : (
  • - + {label} {value ?? commonText.loading()} diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx index b80380b35ac..6cd8eb21414 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx @@ -192,6 +192,10 @@ export function QueryList({ {typeof callBack === 'string' ? ( + /* + * BUG: consider applying these styles everywhere + * className="max-w-full overflow-auto" + */ {text} From 86de81a6642cbf14b7d21d7fd72e4a951e234522 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 14:06:29 -0500 Subject: [PATCH 39/50] Cleanup manual overrdieAjax resources in business rule tests --- .../components/DataModel/__tests__/businessRules.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 71a2d6cec93..670a10e1b5a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -40,8 +40,8 @@ test('uniqueness rules assigned correctly', async () => { const determinationId = 321; const determinationUrl = getResourceApiUrl('Determination', determinationId); const determinationResponse: Partial> = { - resource_uri: determinationUrl, id: determinationId, + resource_uri: determinationUrl, }; const collectionObjectId = 220; @@ -53,8 +53,8 @@ const collectionObjectResponse = { id: collectionObjectId, resource_uri: collectionObjectUrl, catalognumber: '000022002', - collection: getResourceApiUrl('CollectionObject', 4), - determinations: [determinationResponse], + collection: getResourceApiUrl('Collection', 4), + determinations: determinationUrl, }; overrideAjax(collectionObjectUrl, collectionObjectResponse); @@ -70,7 +70,7 @@ describe('business rules', () => { resource.save(); }); - test('determination business rules', () => { + describe('determination business rules', () => { const determination = new schema.models.Determination.Resource({ id: determinationId, }); From 11c329ec135aa503f5db8c404529fa988e4c78aa Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 14:29:45 -0500 Subject: [PATCH 40/50] Move resource creation into test block from describe block --- .../components/DataModel/__tests__/businessRules.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 670a10e1b5a..71d6ae190d0 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -61,10 +61,10 @@ overrideAjax(collectionObjectUrl, collectionObjectResponse); overrideAjax(determinationUrl, determinationResponse); describe('business rules', () => { - const resource = new schema.models.CollectionObject.Resource({ - id: collectionObjectId, - }); test('collectionObject customInit', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); await resource.fetch(); expect(resource.get('collectingEvent')).toBeDefined(); resource.save(); @@ -79,6 +79,9 @@ describe('business rules', () => { expect(determination.get('isCurrent')).toBe(true); }); test('only one determination isCurrent', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); await resource.rgetCollection('determinations').then((collection) => { collection.add(new schema.models.Determination.Resource()); }); From 313914e30ac9532489b150285e0483f5481fdc1f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 14:37:12 -0500 Subject: [PATCH 41/50] Move determination resource creation into test() blocks --- .../DataModel/__tests__/businessRules.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 71d6ae190d0..7121be1bdbf 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -71,14 +71,17 @@ describe('business rules', () => { }); describe('determination business rules', () => { - const determination = new schema.models.Determination.Resource({ - id: determinationId, - }); test('determination customInit', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); await determination.fetch(); expect(determination.get('isCurrent')).toBe(true); }); test('only one determination isCurrent', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); const resource = new schema.models.CollectionObject.Resource({ id: collectionObjectId, }); @@ -88,6 +91,9 @@ describe('business rules', () => { expect(determination.get('isCurrent')).toBe(false); }); test('determination taxon field check', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); const taxonId = 19345; const taxonUrl = getResourceApiUrl('Taxon', taxonId); const taxonResponse = { From b5ddfe41fa0cb74ef850e6a664abc0ad7333a544 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 14:48:28 -0500 Subject: [PATCH 42/50] Move all overrideAjax calls to describe() scope --- .../DataModel/__tests__/businessRules.test.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 7121be1bdbf..0946f91fdc5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -126,24 +126,31 @@ describe('business rules', () => { }); describe('uniquenessRules', () => { + const permitOneId = 1; + overrideAjax(getResourceApiUrl('Permit', permitOneId), { + resource_uri: getResourceApiUrl('Permit', permitOneId), + permitNumber: '20', + }); + + const permitTwoId = 2; + overrideAjax(getResourceApiUrl('Permit', permitTwoId), { + resource_uri: getResourceApiUrl('Permit', permitTwoId), + permitNumber: '20', + }); + + overrideAjax('CollectionObject', { + resource_uri: getResourceApiUrl('CollectionObject', 221), + catalogNumber: '000022002', + }); + test('global uniquenessRule', async () => { - const permitOneId = 1; - overrideAjax(getResourceApiUrl('Permit', permitOneId), { - resource_uri: getResourceApiUrl('Permit', permitOneId), - }); const testPermit = new schema.models.Permit.Resource({ id: permitOneId, - permitNumber: '20', }); - await testPermit.save(); + await testPermit.fetch(); - const permitTwoId = 2; - overrideAjax(getResourceApiUrl('Permit', permitTwoId), { - resource_uri: getResourceApiUrl('Permit', permitTwoId), - }); const duplicatePermit = new schema.models.Permit.Resource({ id: permitTwoId, - permitNumber: '20', }); expect( duplicatePermit @@ -159,13 +166,8 @@ describe('uniquenessRules', () => { }); test('scoped uniqueness rule', async () => { - overrideAjax('CollectionObject', { - resource_uri: getResourceApiUrl('CollectionObject', 221), - catalogNumber: '000022002', - }); const resource = new schema.models.CollectionObject.Resource({ id: 221, - catalogNumber: '000022002', }); expect( resource From 8c71c70cb9268cf0f2dbab106beed3ff1974d605 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 14:55:39 -0500 Subject: [PATCH 43/50] Add missing getResourceApiUrl in overrideAjax call --- .../lib/components/DataModel/__tests__/businessRules.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 0946f91fdc5..24400cc4dc7 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -138,7 +138,7 @@ describe('uniquenessRules', () => { permitNumber: '20', }); - overrideAjax('CollectionObject', { + overrideAjax(getResourceApiUrl('CollectionObject', 221), { resource_uri: getResourceApiUrl('CollectionObject', 221), catalogNumber: '000022002', }); From 05bd279a575715f96c5b7b7a147d7736695bbf5f Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 15:06:41 -0500 Subject: [PATCH 44/50] Save original permit before checking for duplicate in businessRule tests --- .../lib/components/DataModel/__tests__/businessRules.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 24400cc4dc7..bc53009638e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -147,7 +147,7 @@ describe('uniquenessRules', () => { const testPermit = new schema.models.Permit.Resource({ id: permitOneId, }); - await testPermit.fetch(); + await testPermit.save(); const duplicatePermit = new schema.models.Permit.Resource({ id: permitTwoId, From f7ecb0ead9e383a8e3841ea5d6628584a692fe50 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 15:24:52 -0500 Subject: [PATCH 45/50] Add fields to resources for uniqueness rule tests --- .../DataModel/__tests__/businessRules.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index bc53009638e..d71c9e4cb31 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -127,18 +127,23 @@ describe('business rules', () => { describe('uniquenessRules', () => { const permitOneId = 1; - overrideAjax(getResourceApiUrl('Permit', permitOneId), { - resource_uri: getResourceApiUrl('Permit', permitOneId), + const permitOneUrl = getResourceApiUrl('Permit', permitOneId); + overrideAjax(permitOneUrl, { + id: permitOneId, + resource_uri: permitOneUrl, permitNumber: '20', }); const permitTwoId = 2; - overrideAjax(getResourceApiUrl('Permit', permitTwoId), { - resource_uri: getResourceApiUrl('Permit', permitTwoId), + const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); + overrideAjax(permitTwoUrl, { + id: permitTwoId, + resource_uri: permitTwoUrl, permitNumber: '20', }); overrideAjax(getResourceApiUrl('CollectionObject', 221), { + id: 221, resource_uri: getResourceApiUrl('CollectionObject', 221), catalogNumber: '000022002', }); @@ -146,11 +151,13 @@ describe('uniquenessRules', () => { test('global uniquenessRule', async () => { const testPermit = new schema.models.Permit.Resource({ id: permitOneId, + permitNumber: '20', }); await testPermit.save(); const duplicatePermit = new schema.models.Permit.Resource({ id: permitTwoId, + permitNumber: '20', }); expect( duplicatePermit @@ -168,6 +175,7 @@ describe('uniquenessRules', () => { test('scoped uniqueness rule', async () => { const resource = new schema.models.CollectionObject.Resource({ id: 221, + catalogNumber: '000022002', }); expect( resource From a5a3c2ea9612a581da8a41926ce5e7542e45edbe Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 15:38:15 -0500 Subject: [PATCH 46/50] Move overrideAjax calls outside if describe() blocks --- .../DataModel/__tests__/businessRules.test.ts | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index d71c9e4cb31..1aa730e8146 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -125,29 +125,29 @@ describe('business rules', () => { }); }); -describe('uniquenessRules', () => { - const permitOneId = 1; - const permitOneUrl = getResourceApiUrl('Permit', permitOneId); - overrideAjax(permitOneUrl, { - id: permitOneId, - resource_uri: permitOneUrl, - permitNumber: '20', - }); +const permitOneId = 1; +const permitOneUrl = getResourceApiUrl('Permit', permitOneId); +overrideAjax(permitOneUrl, { + id: permitOneId, + resource_uri: permitOneUrl, + permitNumber: '20', +}); - const permitTwoId = 2; - const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); - overrideAjax(permitTwoUrl, { - id: permitTwoId, - resource_uri: permitTwoUrl, - permitNumber: '20', - }); +const permitTwoId = 2; +const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); +overrideAjax(permitTwoUrl, { + id: permitTwoId, + resource_uri: permitTwoUrl, + permitNumber: '20', +}); - overrideAjax(getResourceApiUrl('CollectionObject', 221), { - id: 221, - resource_uri: getResourceApiUrl('CollectionObject', 221), - catalogNumber: '000022002', - }); +overrideAjax(getResourceApiUrl('CollectionObject', 221), { + id: 221, + resource_uri: getResourceApiUrl('CollectionObject', 221), + catalogNumber: '000022002', +}); +describe('uniquenessRules', () => { test('global uniquenessRule', async () => { const testPermit = new schema.models.Permit.Resource({ id: permitOneId, From 672f283d0d5ef31bba3b17512b741634cc69c7ed Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 16:46:36 -0500 Subject: [PATCH 47/50] Rename toOneField to scope --- .../lib/components/DataModel/businessRules.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index d133fe7c9c0..be6769cede7 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -125,7 +125,7 @@ export class BusinessRuleManager { private async checkUnique( fieldName: keyof SCHEMA['fields'] ): Promise { - const toOneFields = + const scopeFields = this.rules?.uniqueIn !== undefined ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? [] @@ -134,19 +134,17 @@ export class BusinessRuleManager { // Typescript thinks that map() does not exist on NonNullable> // However, map() exists on every possible type of UniquenessRule // @ts-expect-error - const results: RA>> = toOneFields.map( + const results: RA>> = scopeFields.map( ( uniqueRule: string | null | { field: string; otherFields: string[] } ) => { - let field = uniqueRule; + let scope = uniqueRule; let fieldNames: string[] | null = [fieldName as string]; - if (uniqueRule === null) { - fieldNames = null; - } else if (typeof uniqueRule != 'string') { + if (uniqueRule !== null && typeof uniqueRule != 'string') { fieldNames = fieldNames.concat(uniqueRule.otherFields); - field = uniqueRule.field; + scope = uniqueRule.field; } - return this.uniqueIn(field as string, fieldNames); + return this.uniqueIn(scope as string, fieldNames); } ); @@ -182,28 +180,28 @@ export class BusinessRuleManager { } private getUniqueInvalidReason( - parentFieldInfo: Relationship | LiteralField, - fieldInfo: RA + scopeField: Relationship | LiteralField | undefined, + field: RA ): string { - if (fieldInfo.length > 1) - return parentFieldInfo + if (field.length > 1) + return scopeField ? formsText.valuesOfMustBeUniqueToField({ - values: formatConjunction(fieldInfo.map((fld) => fld.label)), - fieldName: parentFieldInfo.label, + values: formatConjunction(field.map((fld) => fld.label)), + fieldName: scopeField.label, }) : formsText.valuesOfMustBeUniqueToDatabase({ - values: formatConjunction(fieldInfo.map((fld) => fld.label)), + values: formatConjunction(field.map((fld) => fld.label)), }); else - return parentFieldInfo + return scopeField ? formsText.valueMustBeUniqueToField({ - fieldName: parentFieldInfo.label, + fieldName: scopeField.label, }) : formsText.valueMustBeUniqueToDatabase(); } private uniqueIn( - toOneField: string | undefined | null, + scope: string | undefined | null, fieldNames: RA | string | null ): Promise> { if (fieldNames === null) { @@ -234,9 +232,9 @@ export class BusinessRuleManager { } else return undefined; }); - const toOneFieldInfo = - toOneField !== null && toOneField !== undefined - ? (this.resource.specifyModel.getField(toOneField) as Relationship) + const scopeFieldInfo = + scope !== null && scope !== undefined + ? (this.resource.specifyModel.getField(scope) as Relationship) : undefined; const allNullOrUndefinedToOnes = fieldIds.reduce( @@ -247,11 +245,9 @@ export class BusinessRuleManager { const invalidResponse: BusinessRuleResult = { valid: false, - reason: - toOneFieldInfo !== undefined && - !fieldInfo.some((field) => field === undefined) - ? this.getUniqueInvalidReason(toOneFieldInfo, fieldInfo) - : '', + reason: !fieldInfo.some((field) => field === undefined) + ? this.getUniqueInvalidReason(scopeFieldInfo, fieldInfo) + : '', }; if (allNullOrUndefinedToOnes) return Promise.resolve({ valid: true }); @@ -275,7 +271,7 @@ export class BusinessRuleManager { ); }; - if (toOneField != null) { + if (scope != null) { const localCollection = this.resource.collection?.models !== undefined ? this.resource.collection.models.filter( @@ -293,7 +289,7 @@ export class BusinessRuleManager { } const relatedPromise: Promise> = - this.resource.rgetPromise(toOneField); + this.resource.rgetPromise(scope); return relatedPromise.then((related) => { if (!related) return Promise.resolve({ valid: true }); @@ -303,7 +299,7 @@ export class BusinessRuleManager { } const others = new this.resource.specifyModel.ToOneCollection({ related: related, - field: toOneFieldInfo, + field: scopeFieldInfo, filters: filters as Partial< { readonly orderby: string; From 73c0b847394456835c01dd9e0cb5c4fe40463875 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 16:47:52 -0500 Subject: [PATCH 48/50] Manually specify resource api url --- .../DataModel/__tests__/businessRules.test.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts index 1aa730e8146..96063662487 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -125,29 +125,31 @@ describe('business rules', () => { }); }); -const permitOneId = 1; -const permitOneUrl = getResourceApiUrl('Permit', permitOneId); -overrideAjax(permitOneUrl, { - id: permitOneId, - resource_uri: permitOneUrl, - permitNumber: '20', -}); +describe('uniquenessRules', () => { + const permitOneId = 1; + const permitOneUrl = '/api/specify/permit/1/'; + const permitOneResponse = { + id: permitOneId, + resource_uri: permitOneUrl, + permitNumber: '20', + }; + overrideAjax(permitOneUrl, permitOneResponse); -const permitTwoId = 2; -const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); -overrideAjax(permitTwoUrl, { - id: permitTwoId, - resource_uri: permitTwoUrl, - permitNumber: '20', -}); + const permitTwoId = 2; + const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); + const permitTwoResponse = { + id: permitTwoId, + resource_uri: permitTwoUrl, + permitNumber: '20', + }; + overrideAjax(permitTwoUrl, permitTwoResponse); -overrideAjax(getResourceApiUrl('CollectionObject', 221), { - id: 221, - resource_uri: getResourceApiUrl('CollectionObject', 221), - catalogNumber: '000022002', -}); + overrideAjax(getResourceApiUrl('CollectionObject', 221), { + id: 221, + resource_uri: getResourceApiUrl('CollectionObject', 221), + catalogNumber: '000022002', + }); -describe('uniquenessRules', () => { test('global uniquenessRule', async () => { const testPermit = new schema.models.Permit.Resource({ id: permitOneId, From 60da04bb968698fce3675969ddde3b4cbb781bd2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 19 Apr 2023 16:59:33 -0500 Subject: [PATCH 49/50] Add static file ajax tests --- ...+of+Kansas+Biodiversity+Institute&offset=0 | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 new file mode 100644 index 00000000000..b02c1fb713c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 @@ -0,0 +1,49 @@ +{ + "objects": [ + { + "id": 1, + "altname": "http://grbio.org/cool/iakn-125z", + "code": "KU", + "copyright": "https://creativecommons.org/licenses/by/4.0/legalcode", + "currentmanagedrelversion": "6.4.11", + "currentmanagedschemaversion": null, + "description": null, + "disclaimer": "https://creativecommons.org/licenses/by-nc/4.0/legalcode", + "guid": "77ff1bff-af23-4647-b5d1-9d3c414fd003", + "hasbeenasked": true, + "iconuri": null, + "ipr": null, + "isaccessionsglobal": false, + "isanonymous": false, + "isreleasemanagedglobally": false, + "issecurityon": true, + "isserverbased": false, + "issharinglocalities": false, + "issinglegeographytree": false, + "license": null, + "lsidauthority": null, + "minimumpwdlength": 0, + "name": "University of Kansas Biodiversity Institute", + "regnumber": "1344636808.22", + "remarks": null, + "termsofuse": "http://biodiversity.ku.edu/research/university-kansas-biodiversity-institute-data-publication-and-use-norms", + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2020-06-30T14:01:34", + "uri": null, + "version": 19, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "modifiedbyagent": "/api/specify/agent/1514/", + "storagetreedef": "/api/specify/storagetreedef/1/", + "contentcontacts": "/api/specify/agent/?instcontentcontact=1", + "technicalcontacts": "/api/specify/agent/?insttechcontact=1", + "divisions": "/api/specify/division/?institution=1", + "resource_uri": "/api/specify/institution/1/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } +} From b9eeea064f0d93e54d0670a56c1c8373bf069498 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 2 May 2023 09:40:49 -0500 Subject: [PATCH 50/50] Address code comments --- specifyweb/businessrules/uniqueness_rules.py | 13 +- .../components/DataModel/businessRuleDefs.ts | 69 +++++++--- .../lib/components/DataModel/businessRules.ts | 125 +++++++++--------- .../components/DataModel/uniquness_rules.json | 26 ++-- 4 files changed, 130 insertions(+), 103 deletions(-) diff --git a/specifyweb/businessrules/uniqueness_rules.py b/specifyweb/businessrules/uniqueness_rules.py index db864555c13..da23e0cda3e 100644 --- a/specifyweb/businessrules/uniqueness_rules.py +++ b/specifyweb/businessrules/uniqueness_rules.py @@ -1,6 +1,6 @@ import json from django.core.exceptions import ObjectDoesNotExist -from typing import Dict +from typing import Dict, List, Union from specifyweb.specify import models from .orm_signal_handler import orm_signal_handler from .exceptions import BusinessRuleException @@ -56,14 +56,19 @@ def check_unique(instance): "conflicting" : list(conflicts.values_list('id', flat=True)[:100])}) return check_unique -RAW_UNIQUENESS_RULES: Dict[str, Dict[str, list]] = json.load(open('specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json')) +RAW_UNIQUENESS_RULES: Dict[str, Dict[str, List[Union[Dict[str, Union[str, list]], str, None]]]] = \ + json.load(open('specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json')) def parse_uniqueness_rules(): PARSED_UNIQUENESS_RULES = {} - for table, value in RAW_UNIQUENESS_RULES.items(): + for table, rules in RAW_UNIQUENESS_RULES.items(): table = table.lower().capitalize() if hasattr(models, table): - PARSED_UNIQUENESS_RULES[table] = value + PARSED_UNIQUENESS_RULES[table] = {} + for field_name, rule in rules.items(): + # The Specify Model field names are always in lowercase + field_name = field_name.lower() + PARSED_UNIQUENESS_RULES[table][field_name] = rule return PARSED_UNIQUENESS_RULES diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts index 0e7363ee9db..993fee899e0 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -34,23 +34,30 @@ export type BusinessRuleDefs = { readonly uniqueIn?: UniquenessRule; readonly customInit?: (resource: SpecifyResource) => void; readonly fieldChecks?: { - [FIELD_NAME in TableFields as Lowercase]?: ( + [FIELD_NAME in TableFields]?: ( resource: SpecifyResource ) => Promise | void; }; }; -export const uniqueRules: UniquenessRules = uniquenessRules; +const uniqueRules: JSONUniquenessRules = uniquenessRules; -type UniquenessRules = { - [TABLE in keyof Tables]?: UniquenessRule; +type JSONUniquenessRules = { + [TABLE in keyof Tables]?: JSONUniquenessRule; +}; + +type JSONUniquenessRule = { + [FIELD_NAME in TableFields]?: + | RA + | RA<{ field: string; otherFields: string[] }> + | RA; }; export type UniquenessRule = { - [FIELD_NAME in TableFields as Lowercase]?: + [FIELD_NAME in TableFields]?: | RA - | RA - | RA<{ field: string; otherFields: string[] }>; + | RA<{ field: string; otherFields: string[] }> + | RA; }; type MappedBusinessRuleDefs = { @@ -60,7 +67,7 @@ type MappedBusinessRuleDefs = { export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { BorrowMaterial: { fieldChecks: { - quantityreturned: ( + quantityReturned: ( borrowMaterial: SpecifyResource ): void => { const returned = borrowMaterial.get('quantityReturned'); @@ -80,7 +87,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { newVal && borrowMaterial.set('quantityReturned', newVal); }, - quantityresolved: ( + quantityResolved: ( borrowMaterial: SpecifyResource ): void => { const resolved = borrowMaterial.get('quantityResolved'); @@ -107,7 +114,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { const ceField = collectionObject.specifyModel.getField('collectingEvent'); if ( ceField?.isDependent() && - collectionObject.get('collectingEvent') == undefined + collectionObject.get('collectingEvent') === undefined ) { collectionObject.set( 'collectingEvent', @@ -166,9 +173,9 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }; }); }, - iscurrent: ( + isCurrent: ( determination: SpecifyResource - ): Promise | void => { + ): Promise => { if ( determination.get('isCurrent') && determination.collection != null @@ -202,7 +209,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, DNASequence: { fieldChecks: { - genesequence: (dnaSequence: SpecifyResource): void => { + geneSequence: (dnaSequence: SpecifyResource): void => { const current = dnaSequence.get('geneSequence'); if (current === null) return; const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; @@ -281,9 +288,9 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { updateLoanPrep(resource.collection); }, fieldChecks: { - quantityreturned: ( + quantityReturned: ( loanReturnPrep: SpecifyResource - ): void => { + ) => { const returned = Number(loanReturnPrep.get('quantityReturned'))!; const previousReturned = previousLoanPreparations.previousReturned[loanReturnPrep.cid] ?? 0; @@ -327,7 +334,7 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { returned; updateLoanPrep(loanReturnPrep.collection); }, - quantityresolved: ( + quantityResolved: ( loanReturnPrep: SpecifyResource ): void => { const resolved = Number(loanReturnPrep.get('quantityResolved')); @@ -349,21 +356,39 @@ export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { }, }; -// From this code, Typescript believes that a businessRuleDefs uniqueIn can be from any table -// For example, it believes the following is possible: -// BusinessRuleDefs & {uniqueIn: UniquenessRule | UniquenessRule | ...} +/* From this code, Typescript believes that a businessRuleDefs uniqueIn can be from any table + * For example, it believes the following is possible: + * BusinessRuleDefs & {uniqueIn: UniquenessRule | UniquenessRule | ...} + */ // @ts-expect-error export const businessRuleDefs: MappedBusinessRuleDefs = Object.fromEntries( ( - Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }) as Array< + Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }) as RA< keyof Tables > ).map((table) => { + /* + * To ensure compatibility and consistency with other areas of the frontend, + * the undefined type is preferable over the null type. + * In the JSON uniqueness rules, if a field should be unique at a global (institution) + * level, then it is unique in 'null'. + * Thus we need to replace null with undefined + */ + const uniquenessRules: UniquenessRule | undefined = + uniqueRules[table] !== undefined + ? Object.fromEntries( + Object.entries(uniqueRules[table]!).map(([fieldName, rule]) => { + return [fieldName, rule[0] === null ? [undefined] : rule]; + }) + ) + : undefined; const ruleDefs = nonUniqueBusinessRuleDefs[table] === undefined - ? { uniqueIn: uniqueRules[table] } + ? uniquenessRules === undefined + ? undefined + : { uniqueIn: uniquenessRules } : Object.assign({}, nonUniqueBusinessRuleDefs[table], { - uniqueIn: uniqueRules[table], + uniqueIn: uniquenessRules, }); return [table, ruleDefs]; }) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index be6769cede7..c272a4802ea 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -15,8 +15,8 @@ import { idFromUrl } from './resource'; export class BusinessRuleManager { private readonly resource: SpecifyResource; private readonly rules: BusinessRuleDefs | undefined; - public pendingPromises: Promise = - Promise.resolve(null); + public pendingPromises: Promise = + Promise.resolve(undefined); private fieldChangePromises: { [key: string]: ResolvablePromise; } = {}; @@ -28,12 +28,12 @@ export class BusinessRuleManager { } private addPromise( - promise: Promise + promise: Promise ): void { this.pendingPromises = Promise.allSettled([ this.pendingPromises, promise, - ]).then(() => null); + ]).then(() => undefined); } private changed(resource: SpecifyResource): void { @@ -62,9 +62,7 @@ export class BusinessRuleManager { this.resource.on('remove', this.removed, this); } - public async checkField( - fieldName: keyof SCHEMA['fields'] - ): Promise { + public checkField(fieldName: keyof SCHEMA['fields']) { fieldName = typeof fieldName === 'string' ? fieldName.toLowerCase() : fieldName; const thisCheck: ResolvablePromise = flippedPromise(); @@ -87,7 +85,7 @@ export class BusinessRuleManager { ) ); - return Promise.all(checks) + Promise.all(checks) .then((results) => { return thisCheck === this.fieldChangePromises[fieldName as string] ? this.processCheckFieldResults(fieldName, results) @@ -98,17 +96,15 @@ export class BusinessRuleManager { private processCheckFieldResults( fieldName: keyof SCHEMA['fields'], - results: RA - ): Promise> { - return Promise.all( - results.map((result) => { - if (!result) return null; + results: RA | undefined> + ) { + results.map((result) => { + if (result !== undefined) { if (result.key === undefined) { - if (result.valid) - return typeof result.action === 'function' ? result.action() : null; - return null; - } - if (result.valid === false) { + if (result.valid && typeof result.action === 'function') { + result.action(); + } + } else if (result.valid === false) { this.resource.saveBlockers!.add( result.key, fieldName as string, @@ -116,10 +112,12 @@ export class BusinessRuleManager { ); } else { this.resource.saveBlockers!.remove(result.key); - return typeof result.action === 'function' ? result.action() : null; + if (typeof result.action === 'function') { + result.action(); + } } - }) - ); + } + }); } private async checkUnique( @@ -127,20 +125,16 @@ export class BusinessRuleManager { ): Promise { const scopeFields = this.rules?.uniqueIn !== undefined - ? this.rules?.uniqueIn[fieldName as Lowercase>] ?? - [] + ? this.rules?.uniqueIn[ + this.resource.specifyModel.getField(fieldName as string) + ?.name as TableFields + ] ?? [] : []; - - // Typescript thinks that map() does not exist on NonNullable> - // However, map() exists on every possible type of UniquenessRule - // @ts-expect-error const results: RA>> = scopeFields.map( - ( - uniqueRule: string | null | { field: string; otherFields: string[] } - ) => { + (uniqueRule) => { let scope = uniqueRule; - let fieldNames: string[] | null = [fieldName as string]; - if (uniqueRule !== null && typeof uniqueRule != 'string') { + let fieldNames: string[] | undefined = [fieldName as string]; + if (uniqueRule !== undefined && typeof uniqueRule !== 'string') { fieldNames = fieldNames.concat(uniqueRule.otherFields); scope = uniqueRule.field; } @@ -200,15 +194,15 @@ export class BusinessRuleManager { : formsText.valueMustBeUniqueToDatabase(); } - private uniqueIn( - scope: string | undefined | null, - fieldNames: RA | string | null + private async uniqueIn( + scope: string | undefined, + fieldNames: RA | string | undefined ): Promise> { - if (fieldNames === null) { - return Promise.resolve({ + if (fieldNames === undefined) { + return { valid: false, reason: formsText.valueMustBeUniqueToDatabase(), - }); + }; } fieldNames = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; @@ -223,13 +217,12 @@ export class BusinessRuleManager { ); const fieldIds = fieldValues.map((value, index) => { - if (fieldIsToOne[index] != null) { - if (value == null || value === undefined) { - return null; - } else { + if (fieldIsToOne[index] !== undefined) { + if (value !== undefined && value !== null) { return idFromUrl(value); } - } else return undefined; + } + return undefined; }); const scopeFieldInfo = @@ -245,19 +238,19 @@ export class BusinessRuleManager { const invalidResponse: BusinessRuleResult = { valid: false, - reason: !fieldInfo.some((field) => field === undefined) - ? this.getUniqueInvalidReason(scopeFieldInfo, fieldInfo) - : '', + reason: fieldInfo.some((field) => field === undefined) + ? '' + : this.getUniqueInvalidReason(scopeFieldInfo, fieldInfo), }; - if (allNullOrUndefinedToOnes) return Promise.resolve({ valid: true }); + if (allNullOrUndefinedToOnes) return { valid: true }; const hasSameValues = (other: SpecifyResource): boolean => { const hasSameValue = ( fieldValue: string | number | null, fieldName: string ): boolean => { - if (other.id != null && other.id === this.resource.id) return false; + if (other.id !== null && other.id === this.resource.id) return false; if (other.cid === this.resource.cid) return false; const otherValue = other.get(fieldName); @@ -271,7 +264,7 @@ export class BusinessRuleManager { ); }; - if (scope != null) { + if (scope !== undefined) { const localCollection = this.resource.collection?.models !== undefined ? this.resource.collection.models.filter( @@ -285,14 +278,14 @@ export class BusinessRuleManager { if (duplicates.length > 0) { overwriteReadOnly(invalidResponse, 'localDuplicates', duplicates); - return Promise.resolve(invalidResponse); + return invalidResponse; } const relatedPromise: Promise> = this.resource.rgetPromise(scope); return relatedPromise.then((related) => { - if (!related) return Promise.resolve({ valid: true }); + if (!related) return { valid: true }; const filters: Partial> = {}; for (let f = 0; f < fieldNames!.length; f++) { filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; @@ -356,9 +349,9 @@ export class BusinessRuleManager { ruleName: keyof BusinessRuleDefs, fieldName: keyof SCHEMA['fields'] | undefined, args: RA - ): Promise { + ): Promise { if (this.rules === undefined || ruleName === 'uniqueIn') { - return Promise.resolve(undefined); + return undefined; } let rule = this.rules[ruleName]; @@ -367,21 +360,25 @@ export class BusinessRuleManager { ruleName === 'fieldChecks' && fieldName !== undefined ) - rule = rule[fieldName as keyof typeof rule]; - - if (rule === undefined) return Promise.resolve(undefined); - - // For some reason, Typescript still thinks that this.rules["fieldChecks"] is a valid rule - // thus rule.apply() would be invalid - // However, rule will never be this.rules["fieldChecks"] + rule = + rule[ + this.resource.specifyModel.getField(fieldName as string) + ?.name as keyof typeof rule + ]; + + if (rule === undefined) return undefined; + + /* + * For some reason, Typescript still thinks that this.rules["fieldChecks"] is a valid rule + * thus rule.apply() would be invalid + * However, rule will never be this.rules["fieldChecks"] + */ // @ts-expect-error - return Promise.resolve(rule.apply(undefined, args)); + return rule.apply(undefined, args); } } -export function attachBusinessRules( - resource: SpecifyResource -): void { +export function attachBusinessRules(resource: SpecifyResource) { const businessRuleManager = new BusinessRuleManager(resource); overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); overwriteReadOnly(resource, 'businessRuleManager', businessRuleManager); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json index 39e2c0ba668..3f15eb2d4b8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -1,6 +1,6 @@ { "Accession":{ - "accessionnumber":[ + "accessionNumber":[ "division" ] }, @@ -35,7 +35,7 @@ ] }, "Appraisal":{ - "appraisalnumber":[ + "appraisalNumber":[ "accession" ] }, @@ -66,7 +66,7 @@ ] }, "Collection":{ - "collectionname":[ + "collectionName":[ "discipline" ], "code":[ @@ -74,15 +74,15 @@ ] }, "CollectingEvent": { - "uniqueidentifier" : [ + "uniqueIdentifier" : [ "discipline" ] }, "CollectionObject":{ - "catalognumber":[ + "catalogNumber":[ "collection" ], - "uniqueidentifier" : [ + "uniqueIdentifier" : [ "collection" ], "guid":[ @@ -138,7 +138,7 @@ ] }, "Gift":{ - "giftnumber":[ + "giftNumber":[ "discipline" ] }, @@ -171,7 +171,7 @@ ] }, "Loan":{ - "loannumber":[ + "loanNumber":[ "discipline" ] }, @@ -194,12 +194,12 @@ ] }, "Locality" : { - "uniqueidentifier" : [ + "uniqueIdentifier" : [ "discipline" ] }, "LocalityCitation" : { - "referencework" : [ + "referenceWork" : [ "locality" ] }, @@ -209,7 +209,7 @@ ] }, "Permit":{ - "permitnumber":[ + "permitNumber":[ null ] }, @@ -224,12 +224,12 @@ ] }, "RepositoryAgreement":{ - "repositoryagreementnumber":[ + "repositoryAgreementNumber":[ "division" ] }, "SpAppResourceData":{ - "spappresource":[ + "spAppResource":[ null ] },