diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index baef46664ac..8090f1ee5d2 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { useAsyncState, usePromise } from '../../hooks/useAsyncState'; import { useCachedState } from '../../hooks/useCachedState'; -import { useCollection } from '../../hooks/useCollection'; +import { useSerializedCollection } from '../../hooks/useSerializedCollection'; import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; @@ -125,7 +125,7 @@ function Attachments({ 'scale' ); - const [collection, setCollection, fetchMore] = useCollection( + const [collection, setCollection, fetchMore] = useSerializedCollection( React.useCallback( async (offset) => fetchCollection( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap index 9dc7c8a8c16..74d383dda9e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap @@ -27,6 +27,7 @@ exports[`fields are loaded 1`] = ` "[literalField CollectionObject.text2]", "[literalField CollectionObject.inventoryDate]", "[literalField CollectionObject.inventoryDatePrecision]", + "[literalField CollectionObject.isMemberOfCOG]", "[literalField CollectionObject.modifier]", "[literalField CollectionObject.name]", "[literalField CollectionObject.notifications]", @@ -104,6 +105,111 @@ exports[`fields are loaded 1`] = ` ] `; +exports[`indexed fields are loaded 1`] = ` +{ + "absoluteAges": "[relationship CollectionObject.absoluteAges]", + "accession": "[relationship CollectionObject.accession]", + "actualTotalCountAmt": "[literalField CollectionObject.actualTotalCountAmt]", + "age": "[literalField CollectionObject.age]", + "agent1": "[relationship CollectionObject.agent1]", + "altCatalogNumber": "[literalField CollectionObject.altCatalogNumber]", + "appraisal": "[relationship CollectionObject.appraisal]", + "availability": "[literalField CollectionObject.availability]", + "catalogNumber": "[literalField CollectionObject.catalogNumber]", + "catalogedDate": "[literalField CollectionObject.catalogedDate]", + "catalogedDatePrecision": "[literalField CollectionObject.catalogedDatePrecision]", + "catalogedDateVerbatim": "[literalField CollectionObject.catalogedDateVerbatim]", + "cataloger": "[relationship CollectionObject.cataloger]", + "cojo": "[relationship CollectionObject.cojo]", + "collectingEvent": "[relationship CollectionObject.collectingEvent]", + "collection": "[relationship CollectionObject.collection]", + "collectionMemberId": "[literalField CollectionObject.collectionMemberId]", + "collectionObjectAttachments": "[relationship CollectionObject.collectionObjectAttachments]", + "collectionObjectAttribute": "[relationship CollectionObject.collectionObjectAttribute]", + "collectionObjectAttrs": "[relationship CollectionObject.collectionObjectAttrs]", + "collectionObjectCitations": "[relationship CollectionObject.collectionObjectCitations]", + "collectionObjectProperties": "[relationship CollectionObject.collectionObjectProperties]", + "collectionObjectType": "[relationship CollectionObject.collectionObjectType]", + "conservDescriptions": "[relationship CollectionObject.conservDescriptions]", + "container": "[relationship CollectionObject.container]", + "containerOwner": "[relationship CollectionObject.containerOwner]", + "countAmt": "[literalField CollectionObject.countAmt]", + "createdByAgent": "[relationship CollectionObject.createdByAgent]", + "currentDetermination": "[relationship CollectionObject.currentDetermination]", + "date1": "[literalField CollectionObject.date1]", + "date1Precision": "[literalField CollectionObject.date1Precision]", + "deaccessioned": "[literalField CollectionObject.deaccessioned]", + "description": "[literalField CollectionObject.description]", + "determinations": "[relationship CollectionObject.determinations]", + "dnaSequences": "[relationship CollectionObject.dnaSequences]", + "embargoAuthority": "[relationship CollectionObject.embargoAuthority]", + "embargoReason": "[literalField CollectionObject.embargoReason]", + "embargoReleaseDate": "[literalField CollectionObject.embargoReleaseDate]", + "embargoReleaseDatePrecision": "[literalField CollectionObject.embargoReleaseDatePrecision]", + "embargoStartDate": "[literalField CollectionObject.embargoStartDate]", + "embargoStartDatePrecision": "[literalField CollectionObject.embargoStartDatePrecision]", + "exsiccataItems": "[relationship CollectionObject.exsiccataItems]", + "fieldNotebookPage": "[relationship CollectionObject.fieldNotebookPage]", + "fieldNumber": "[literalField CollectionObject.fieldNumber]", + "guid": "[literalField CollectionObject.guid]", + "integer1": "[literalField CollectionObject.integer1]", + "integer2": "[literalField CollectionObject.integer2]", + "inventorizedBy": "[relationship CollectionObject.inventorizedBy]", + "inventoryDate": "[literalField CollectionObject.inventoryDate]", + "inventoryDatePrecision": "[literalField CollectionObject.inventoryDatePrecision]", + "isMemberOfCOG": "[literalField CollectionObject.isMemberOfCOG]", + "leftSideRels": "[relationship CollectionObject.leftSideRels]", + "modifiedByAgent": "[relationship CollectionObject.modifiedByAgent]", + "modifier": "[literalField CollectionObject.modifier]", + "name": "[literalField CollectionObject.name]", + "notifications": "[literalField CollectionObject.notifications]", + "number1": "[literalField CollectionObject.number1]", + "number2": "[literalField CollectionObject.number2]", + "numberOfDuplicates": "[literalField CollectionObject.numberOfDuplicates]", + "objectCondition": "[literalField CollectionObject.objectCondition]", + "ocr": "[literalField CollectionObject.ocr]", + "otherIdentifiers": "[relationship CollectionObject.otherIdentifiers]", + "paleoContext": "[relationship CollectionObject.paleoContext]", + "preparations": "[relationship CollectionObject.preparations]", + "projectNumber": "[literalField CollectionObject.projectNumber]", + "projects": "[relationship CollectionObject.projects]", + "relativeAges": "[relationship CollectionObject.relativeAges]", + "remarks": "[literalField CollectionObject.remarks]", + "reservedInteger3": "[literalField CollectionObject.reservedInteger3]", + "reservedInteger4": "[literalField CollectionObject.reservedInteger4]", + "reservedText": "[literalField CollectionObject.reservedText]", + "reservedText2": "[literalField CollectionObject.reservedText2]", + "reservedText3": "[literalField CollectionObject.reservedText3]", + "restrictions": "[literalField CollectionObject.restrictions]", + "rightSideRels": "[relationship CollectionObject.rightSideRels]", + "sgrStatus": "[literalField CollectionObject.sgrStatus]", + "text1": "[literalField CollectionObject.text1]", + "text2": "[literalField CollectionObject.text2]", + "text3": "[literalField CollectionObject.text3]", + "text4": "[literalField CollectionObject.text4]", + "text5": "[literalField CollectionObject.text5]", + "text6": "[literalField CollectionObject.text6]", + "text7": "[literalField CollectionObject.text7]", + "text8": "[literalField CollectionObject.text8]", + "timestampCreated": "[literalField CollectionObject.timestampCreated]", + "timestampModified": "[literalField CollectionObject.timestampModified]", + "totalCountAmt": "[literalField CollectionObject.totalCountAmt]", + "totalValue": "[literalField CollectionObject.totalValue]", + "treatmentEvents": "[relationship CollectionObject.treatmentEvents]", + "uniqueIdentifier": "[literalField CollectionObject.uniqueIdentifier]", + "version": "[literalField CollectionObject.version]", + "visibility": "[literalField CollectionObject.visibility]", + "visibilitySetBy": "[relationship CollectionObject.visibilitySetBy]", + "voucherRelationships": "[relationship CollectionObject.voucherRelationships]", + "yesNo1": "[literalField CollectionObject.yesNo1]", + "yesNo2": "[literalField CollectionObject.yesNo2]", + "yesNo3": "[literalField CollectionObject.yesNo3]", + "yesNo4": "[literalField CollectionObject.yesNo4]", + "yesNo5": "[literalField CollectionObject.yesNo5]", + "yesNo6": "[literalField CollectionObject.yesNo6]", +} +`; + exports[`literal fields are loaded 1`] = ` [ "[literalField CollectionObject.actualTotalCountAmt]", @@ -131,6 +237,7 @@ exports[`literal fields are loaded 1`] = ` "[literalField CollectionObject.text2]", "[literalField CollectionObject.inventoryDate]", "[literalField CollectionObject.inventoryDatePrecision]", + "[literalField CollectionObject.isMemberOfCOG]", "[literalField CollectionObject.modifier]", "[literalField CollectionObject.name]", "[literalField CollectionObject.notifications]", 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 61c84052045..87de7d4969b 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 @@ -306,6 +306,11 @@ describe('uniqueness rules', () => { ]); }); + overrideAjax(getResourceApiUrl('Agent', 1), { + id: 1, + resource_uri: getResourceApiUrl('Agent', 1), + }); + test('rule with local collection', async () => { const accessionId = 1; const accession = new tables.Accession.Resource({ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collectionApi.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collectionApi.test.ts index db1747601f7..7caab498f0d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collectionApi.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/collectionApi.test.ts @@ -1,30 +1,33 @@ import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import { overwriteReadOnly } from '../../../utils/types'; +import type { CollectionFetchFilters } from '../collection'; +import { DEFAULT_FETCH_LIMIT } from '../collection'; +import type { AnySchema } from '../helperTypes'; import { getResourceApiUrl } from '../resource'; import type { Collection } from '../specifyTable'; import { tables } from '../tables'; -import type { Accession, Agent } from '../types'; +import type { Accession, Agent, CollectionObject } from '../types'; requireContext(); -const secondAccessionUrl = getResourceApiUrl('Accession', 12); -const accessionId = 11; -const accessionUrl = getResourceApiUrl('Accession', accessionId); -const accessionNumber = '2011-IC-116'; -const accessionsResponse = [ - { - resource_uri: accessionUrl, - id: 11, - accessionnumber: accessionNumber, - }, - { - resource_uri: secondAccessionUrl, - id: 12, - }, -]; - describe('LazyCollection', () => { + const secondAccessionUrl = getResourceApiUrl('Accession', 12); + const accessionId = 11; + const accessionUrl = getResourceApiUrl('Accession', accessionId); + const accessionNumber = '2011-IC-116'; + const accessionsResponse = [ + { + resource_uri: accessionUrl, + id: 11, + accessionnumber: accessionNumber, + }, + { + resource_uri: secondAccessionUrl, + id: 12, + }, + ]; + overrideAjax( '/api/specify/accession/?domainfilter=false&addressofrecord=4&offset=0', { @@ -71,3 +74,402 @@ describe('LazyCollection', () => { expect(collection.toJSON()).toEqual(accessionsResponse); }); }); + +describe('Independent Collection', () => { + const collectionObjectsResponse = Array.from({ length: 41 }, (_, index) => ({ + id: index + 1, + resource_uri: getResourceApiUrl('CollectionObject', index + 1), + })); + + overrideAjax( + '/api/specify/collectionobject/?domainfilter=false&accession=1&offset=0', + { + objects: collectionObjectsResponse.slice(0, DEFAULT_FETCH_LIMIT), + meta: { + limit: DEFAULT_FETCH_LIMIT, + total_count: collectionObjectsResponse.length, + }, + } + ); + + overrideAjax( + `/api/specify/collectionobject/?domainfilter=false&accession=1&offset=${DEFAULT_FETCH_LIMIT}`, + { + objects: collectionObjectsResponse.slice( + DEFAULT_FETCH_LIMIT, + DEFAULT_FETCH_LIMIT * 2 + ), + meta: { + limit: DEFAULT_FETCH_LIMIT, + total_count: collectionObjectsResponse.length, + }, + } + ); + + overrideAjax( + `/api/specify/collectionobject/?domainfilter=false&accession=1&offset=${ + DEFAULT_FETCH_LIMIT * 2 + }`, + { + objects: collectionObjectsResponse.slice(DEFAULT_FETCH_LIMIT * 2), + meta: { + limit: DEFAULT_FETCH_LIMIT, + total_count: collectionObjectsResponse.length, + }, + } + ); + + overrideAjax( + '/api/specify/collectionobject/?domainfilter=false&accession=1&offset=20&limit=0', + { + objects: collectionObjectsResponse.slice(DEFAULT_FETCH_LIMIT), + meta: { + limit: 0, + total_count: collectionObjectsResponse.length, + }, + } + ); + + test('lazily fetched', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + expect(collection._totalCount).toBe(collectionObjectsResponse.length); + expect(collection).toHaveLength(DEFAULT_FETCH_LIMIT); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse + .slice(0, DEFAULT_FETCH_LIMIT) + .map(({ id }) => id) + ); + + await collection.fetch(); + expect(collection).toHaveLength(DEFAULT_FETCH_LIMIT * 2); + expect( + collection.models + .slice(DEFAULT_FETCH_LIMIT, DEFAULT_FETCH_LIMIT * 2) + .map(({ id }) => id) + ).toStrictEqual( + collectionObjectsResponse + .slice(DEFAULT_FETCH_LIMIT, DEFAULT_FETCH_LIMIT * 2) + .map(({ id }) => id) + ); + + await collection.fetch(); + // eslint-disable-next-line jest/prefer-to-have-length + expect(collection.length).toBe(collection._totalCount); + }); + + test('specified offset', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch({ + offset: DEFAULT_FETCH_LIMIT, + }); + expect(collection).toHaveLength(DEFAULT_FETCH_LIMIT); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse + .slice(DEFAULT_FETCH_LIMIT, DEFAULT_FETCH_LIMIT * 2) + .map(({ id }) => id) + ); + }); + + test('reset', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch({ + offset: DEFAULT_FETCH_LIMIT, + limit: 0, + }); + expect(collection).toHaveLength( + collectionObjectsResponse.length - DEFAULT_FETCH_LIMIT + ); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse.slice(DEFAULT_FETCH_LIMIT).map(({ id }) => id) + ); + await collection.fetch({ + reset: true, + offset: 0, + } as CollectionFetchFilters); + expect(collection).toHaveLength(DEFAULT_FETCH_LIMIT); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse + .slice(0, DEFAULT_FETCH_LIMIT) + .map(({ id }) => id) + ); + }); + + test('removed objects not refetched', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + const collectionObjectsToRemove = collection.models + .slice(0, 5) + .map((collectionObject) => ({ ...collectionObject })); + collectionObjectsToRemove.forEach((collectionObject) => + collection.remove(collectionObject) + ); + await collection.fetch({ offset: 0 }); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse + .slice(5, DEFAULT_FETCH_LIMIT) + .map(({ id }) => id) + ); + }); + + test('offset adjusted when all models removed', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + const collectionObjectsToRemove = collection.models.map( + (collectionObject) => ({ ...collectionObject }) + ); + collectionObjectsToRemove.forEach((collectionObject) => + collection.remove(collectionObject) + ); + expect(collection.getFetchOffset()).toBe(DEFAULT_FETCH_LIMIT); + await collection.fetch(); + expect(collection.models.map(({ id }) => id)).toStrictEqual( + collectionObjectsResponse + .slice(DEFAULT_FETCH_LIMIT, DEFAULT_FETCH_LIMIT * 2) + .map(({ id }) => id) + ); + }); + + test('on resource change event', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + + expect(collection._totalCount).toBe(collectionObjectsResponse.length); + + collection.models[0].set('text1', 'someValue'); + expect( + Object.values(collection.updated ?? {}).map((resource) => + typeof resource === 'string' ? resource : resource.toJSON() + ) + ).toStrictEqual([ + { + id: 1, + resource_uri: '/api/specify/collectionobject/1/', + collectionobjecttype: '/api/specify/collectionobjecttype/1/', + text1: 'someValue', + }, + ]); + }); + + overrideAjax('/api/specify/accession/1/', { + id: 1, + resource_uri: getResourceApiUrl('Accession', 1), + }); + + overrideAjax('/api/specify/collectionobject/1/', { + id: 1, + resource_uri: getResourceApiUrl('CollectionObject', 1), + }); + + test('on change toOne', async () => { + const collectionObject = new tables.CollectionObject.Resource({ id: 1 }); + + const collection = new tables.Accession.IndependentCollection({ + related: collectionObject, + field: tables.Accession.strictGetRelationship('collectionObjects'), + }) as Collection; + + const rawAccession = new tables.Accession.Resource({ id: 1 }); + const accession = await rawAccession.fetch(); + + expect(collectionObject.get('accession')).toBeUndefined(); + collection.add(accession); + expect(collection.updated?.[accession.cid]).toBe( + getResourceApiUrl('Accession', 1) + ); + accession.set('accessionNumber', '2011-IC-116'); + expect(collection.updated?.[accession.cid]).toBe(accession); + expect(collectionObject.get('accession')).toBe( + getResourceApiUrl('Accession', 1) + ); + }); + + test('on add event', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + + const newCollectionObjects = [ + new tables.CollectionObject.Resource(), + new tables.CollectionObject.Resource({ id: 100 }), + ]; + collection.add(newCollectionObjects); + expect(collection._totalCount).toBe( + collectionObjectsResponse.length + newCollectionObjects.length + ); + expect(Object.keys(collection.updated ?? {})).toStrictEqual( + newCollectionObjects.map(({ cid }) => cid) + ); + newCollectionObjects.forEach((collectionObject) => { + const updatedEntry = collection.updated?.[collectionObject.cid]; + expect(updatedEntry).toBe( + collectionObject.isNew() ? collectionObject : collectionObject.url() + ); + }); + }); + test('on remove event', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + + const collection = await rawCollection.fetch(); + + const collectionObjectsToRemove = collection.models.slice(0, 3); + collectionObjectsToRemove.forEach((collectionObject) => + collection.remove(collectionObject) + ); + expect(collection._totalCount).toBe( + collectionObjectsResponse.length - collectionObjectsToRemove.length + ); + expect(Array.from(collection.removed ?? [])).toStrictEqual( + collectionObjectsToRemove.map((resource) => resource.get('resource_uri')) + ); + }); + test('removed and updated modify eachother', () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const collection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }) as Collection; + const collectionObject = new tables.CollectionObject.Resource({ id: 1 }); + collection.add(collectionObject); + expect(collection.updated).toStrictEqual({ + [collectionObject.cid]: collectionObject.url(), + }); + collection.remove(collectionObject); + expect(collection.removed).toStrictEqual(new Set([collectionObject.url()])); + expect(collection.updated).toStrictEqual({}); + collection.add(collectionObject); + expect(collection.updated).toStrictEqual({ + [collectionObject.cid]: collectionObject.url(), + }); + expect(collection.removed).toStrictEqual(new Set()); + }); + + test('success options respected', async () => { + const accession = new tables.Accession.Resource(); + + expect(accession.isNew()).toBe(true); + + const collection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }) as Collection; + + await collection.fetch({ + success: (collection) => { + collection.add(new tables.CollectionObject.Resource()); + }, + } as CollectionFetchFilters); + expect(collection.models).toHaveLength(1); + }); + + overrideAjax('/api/specify/collectionobject/200/', { + id: 200, + resource_uri: getResourceApiUrl('CollectionObject', 200), + }); + + test('toApiJSON', async () => { + const accession = new tables.Accession.Resource({ + id: 1, + }); + + const rawCollection = new tables.CollectionObject.IndependentCollection({ + related: accession, + field: tables.CollectionObject.strictGetRelationship('accession'), + }); + const collection = await rawCollection.fetch(); + expect(collection.toApiJSON()).toStrictEqual({ + update: [], + remove: [], + }); + const collectionObjectsToRemove = collection.models + .slice(1, 4) + .map((collectionObject) => collectionObject); + + collectionObjectsToRemove.forEach((collectionObject) => { + collection.remove(collectionObject); + }); + + const collectionObjectsToAdd = [ + new tables.CollectionObject.Resource({ id: 200 }), + new tables.CollectionObject.Resource({ text1: 'someValue' }), + ]; + collection.add(collectionObjectsToAdd); + collection.models[0].set('catalogNumber', '000000001'); + + expect(collection.toApiJSON()).toStrictEqual({ + remove: collectionObjectsToRemove.map((collectionObject) => + collectionObject.get('resource_uri') + ), + update: [ + '/api/specify/collectionobject/200/', + collection.models.at(-1), + collection.models[0], + ], + }); + }); +}); 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 733cc18e7bd..787ed89f2cf 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', () => { 'catalogNumber', 'timestampModified', 'guid', + 'isMemberOfCOG', 'timestampCreated', 'totalCountAmt', 'uniqueIdentifier', @@ -309,6 +310,7 @@ test('getFieldsToNotClone', () => { 'catalogNumber', 'timestampModified', 'guid', + 'isMemberOfCOG', 'text1', 'timestampCreated', 'totalCountAmt', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts index 5c765575805..af8bab872c9 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resourceApi.test.ts @@ -2,6 +2,7 @@ import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import type { RA } from '../../../utils/types'; import { replaceItem } from '../../../utils/utils'; +import { addMissingFields } from '../addMissingFields'; import type { SerializedRecord } from '../helperTypes'; import { getResourceApiUrl } from '../resource'; import { tables } from '../tables'; @@ -21,7 +22,11 @@ const collectionObjectUrl = getResourceApiUrl( ); const accessionId = 11; const accessionUrl = getResourceApiUrl('Accession', accessionId); -const collectingEventUrl = getResourceApiUrl('CollectingEvent', 8868); +const collectingEventId = 8868; +const collectingEventUrl = getResourceApiUrl( + 'CollectingEvent', + collectingEventId +); const determinationUrl = getResourceApiUrl('Determination', 123); const determinationsResponse: RA>> = [ @@ -65,9 +70,12 @@ const accessionResponse = { }; overrideAjax(accessionUrl, accessionResponse); +const collectingEventText = 'testCollectingEvent'; + const collectingEventResponse = { resource_uri: collectingEventUrl, - id: 8868, + text1: collectingEventText, + id: collectingEventId, }; overrideAjax(collectingEventUrl, collectingEventResponse); @@ -183,6 +191,19 @@ describe('rgetCollection', () => { expect(firstCollectingEvent).not.toBe(secondCollectingEvent); }); + test('call for independent refetches related', async () => { + const resource = new tables.CollectionObject.Resource({ + id: collectionObjectId, + }); + const newCollectingEvent = new tables.CollectingEvent.Resource({ + id: collectingEventId, + text1: 'someOtherText', + }); + resource.set('collectingEvent', newCollectingEvent); + const firstCollectingEvent = await resource.rgetPromise('collectingEvent'); + expect(firstCollectingEvent?.get('text1')).toEqual(collectingEventText); + }); + test('repeated calls for dependent return same object', async () => { const resource = new tables.CollectionObject.Resource({ id: collectionObjectId, @@ -199,6 +220,90 @@ describe('rgetCollection', () => { // TEST: add dependent and independent tests for all relationship types (and zero-to-one) }); +describe('eventHandlerForToMany', () => { + test('saverequired', () => { + const resource = new tables.CollectionObject.Resource( + addMissingFields('CollectionObject', { + preparations: [ + { + id: 1, + _tableName: 'Preparation', + }, + ], + }) + ); + const testFunction = jest.fn(); + resource.on('saverequired', testFunction); + expect(testFunction).toHaveBeenCalledTimes(0); + expect(resource.needsSaved).toBe(false); + resource + .getDependentResource('preparations') + ?.models[0].set('text1', 'helloWorld'); + + expect(resource.needsSaved).toBe(true); + expect(testFunction).toHaveBeenCalledTimes(1); + }); + test('changing collection propagates to related', () => { + const resource = new tables.CollectionObject.Resource( + addMissingFields('CollectionObject', { + preparations: [ + { + id: 1, + _tableName: 'Preparation', + }, + ], + }) + ); + const onResourceChange = jest.fn(); + const onPrepChange = jest.fn(); + const onPrepAdd = jest.fn(); + const onPrepRemoval = jest.fn(); + resource.on('change', onResourceChange); + resource.on('change:preparations', onPrepChange); + resource.on('add:preparations', onPrepAdd); + resource.on('remove:preparations', onPrepRemoval); + + resource + .getDependentResource('preparations') + ?.models[0].set('text1', 'helloWorld', { silent: false }); + expect(onResourceChange).toHaveBeenCalledWith( + resource, + resource.getDependentResource('preparations') + ); + expect(onPrepChange).toHaveBeenCalledWith( + resource.getDependentResource('preparations')?.models[0], + { silent: false } + ); + const newPrep = new tables.Preparation.Resource({ + barCode: 'test', + }); + resource.getDependentResource('preparations')?.add(newPrep); + expect(onPrepAdd).toHaveBeenCalledWith( + newPrep, + resource.getDependentResource('preparations'), + {} + ); + resource.getDependentResource('preparations')?.remove(newPrep); + expect(onPrepRemoval).toHaveBeenCalledWith( + newPrep, + resource.getDependentResource('preparations'), + { index: 1 } + ); + + expect(onResourceChange).toHaveBeenCalledTimes(3); + + resource.set('determinations', [ + addMissingFields('Determination', { + taxon: getResourceApiUrl('Taxon', 1), + }), + ]); + expect(onResourceChange).toHaveBeenCalledTimes(4); + expect(onPrepChange).toHaveBeenCalledTimes(1); + expect(onPrepAdd).toHaveBeenCalledTimes(1); + expect(onPrepRemoval).toHaveBeenCalledTimes(1); + }); +}); + describe('needsSaved', () => { test('changing field makes needsSaved true', () => { const resource = new tables.CollectionObject.Resource({ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/specifyTable.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/specifyTable.test.ts index e49d2d3d190..07b923db338 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/specifyTable.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/specifyTable.test.ts @@ -364,106 +364,4 @@ test('tableScoping', () => ).toMatchSnapshot()); test('indexed fields are loaded', () => - expect(tables.CollectionObject.field).toMatchInlineSnapshot(` - { - "absoluteAges": "[relationship CollectionObject.absoluteAges]", - "accession": "[relationship CollectionObject.accession]", - "actualTotalCountAmt": "[literalField CollectionObject.actualTotalCountAmt]", - "age": "[literalField CollectionObject.age]", - "agent1": "[relationship CollectionObject.agent1]", - "altCatalogNumber": "[literalField CollectionObject.altCatalogNumber]", - "appraisal": "[relationship CollectionObject.appraisal]", - "availability": "[literalField CollectionObject.availability]", - "catalogNumber": "[literalField CollectionObject.catalogNumber]", - "catalogedDate": "[literalField CollectionObject.catalogedDate]", - "catalogedDatePrecision": "[literalField CollectionObject.catalogedDatePrecision]", - "catalogedDateVerbatim": "[literalField CollectionObject.catalogedDateVerbatim]", - "cataloger": "[relationship CollectionObject.cataloger]", - "cojo": "[relationship CollectionObject.cojo]", - "collectingEvent": "[relationship CollectionObject.collectingEvent]", - "collection": "[relationship CollectionObject.collection]", - "collectionMemberId": "[literalField CollectionObject.collectionMemberId]", - "collectionObjectAttachments": "[relationship CollectionObject.collectionObjectAttachments]", - "collectionObjectAttribute": "[relationship CollectionObject.collectionObjectAttribute]", - "collectionObjectAttrs": "[relationship CollectionObject.collectionObjectAttrs]", - "collectionObjectCitations": "[relationship CollectionObject.collectionObjectCitations]", - "collectionObjectProperties": "[relationship CollectionObject.collectionObjectProperties]", - "collectionObjectType": "[relationship CollectionObject.collectionObjectType]", - "conservDescriptions": "[relationship CollectionObject.conservDescriptions]", - "container": "[relationship CollectionObject.container]", - "containerOwner": "[relationship CollectionObject.containerOwner]", - "countAmt": "[literalField CollectionObject.countAmt]", - "createdByAgent": "[relationship CollectionObject.createdByAgent]", - "currentDetermination": "[relationship CollectionObject.currentDetermination]", - "date1": "[literalField CollectionObject.date1]", - "date1Precision": "[literalField CollectionObject.date1Precision]", - "deaccessioned": "[literalField CollectionObject.deaccessioned]", - "description": "[literalField CollectionObject.description]", - "determinations": "[relationship CollectionObject.determinations]", - "dnaSequences": "[relationship CollectionObject.dnaSequences]", - "embargoAuthority": "[relationship CollectionObject.embargoAuthority]", - "embargoReason": "[literalField CollectionObject.embargoReason]", - "embargoReleaseDate": "[literalField CollectionObject.embargoReleaseDate]", - "embargoReleaseDatePrecision": "[literalField CollectionObject.embargoReleaseDatePrecision]", - "embargoStartDate": "[literalField CollectionObject.embargoStartDate]", - "embargoStartDatePrecision": "[literalField CollectionObject.embargoStartDatePrecision]", - "exsiccataItems": "[relationship CollectionObject.exsiccataItems]", - "fieldNotebookPage": "[relationship CollectionObject.fieldNotebookPage]", - "fieldNumber": "[literalField CollectionObject.fieldNumber]", - "guid": "[literalField CollectionObject.guid]", - "integer1": "[literalField CollectionObject.integer1]", - "integer2": "[literalField CollectionObject.integer2]", - "inventorizedBy": "[relationship CollectionObject.inventorizedBy]", - "inventoryDate": "[literalField CollectionObject.inventoryDate]", - "inventoryDatePrecision": "[literalField CollectionObject.inventoryDatePrecision]", - "leftSideRels": "[relationship CollectionObject.leftSideRels]", - "modifiedByAgent": "[relationship CollectionObject.modifiedByAgent]", - "modifier": "[literalField CollectionObject.modifier]", - "name": "[literalField CollectionObject.name]", - "notifications": "[literalField CollectionObject.notifications]", - "number1": "[literalField CollectionObject.number1]", - "number2": "[literalField CollectionObject.number2]", - "numberOfDuplicates": "[literalField CollectionObject.numberOfDuplicates]", - "objectCondition": "[literalField CollectionObject.objectCondition]", - "ocr": "[literalField CollectionObject.ocr]", - "otherIdentifiers": "[relationship CollectionObject.otherIdentifiers]", - "paleoContext": "[relationship CollectionObject.paleoContext]", - "preparations": "[relationship CollectionObject.preparations]", - "projectNumber": "[literalField CollectionObject.projectNumber]", - "projects": "[relationship CollectionObject.projects]", - "relativeAges": "[relationship CollectionObject.relativeAges]", - "remarks": "[literalField CollectionObject.remarks]", - "reservedInteger3": "[literalField CollectionObject.reservedInteger3]", - "reservedInteger4": "[literalField CollectionObject.reservedInteger4]", - "reservedText": "[literalField CollectionObject.reservedText]", - "reservedText2": "[literalField CollectionObject.reservedText2]", - "reservedText3": "[literalField CollectionObject.reservedText3]", - "restrictions": "[literalField CollectionObject.restrictions]", - "rightSideRels": "[relationship CollectionObject.rightSideRels]", - "sgrStatus": "[literalField CollectionObject.sgrStatus]", - "text1": "[literalField CollectionObject.text1]", - "text2": "[literalField CollectionObject.text2]", - "text3": "[literalField CollectionObject.text3]", - "text4": "[literalField CollectionObject.text4]", - "text5": "[literalField CollectionObject.text5]", - "text6": "[literalField CollectionObject.text6]", - "text7": "[literalField CollectionObject.text7]", - "text8": "[literalField CollectionObject.text8]", - "timestampCreated": "[literalField CollectionObject.timestampCreated]", - "timestampModified": "[literalField CollectionObject.timestampModified]", - "totalCountAmt": "[literalField CollectionObject.totalCountAmt]", - "totalValue": "[literalField CollectionObject.totalValue]", - "treatmentEvents": "[relationship CollectionObject.treatmentEvents]", - "uniqueIdentifier": "[literalField CollectionObject.uniqueIdentifier]", - "version": "[literalField CollectionObject.version]", - "visibility": "[literalField CollectionObject.visibility]", - "visibilitySetBy": "[relationship CollectionObject.visibilitySetBy]", - "voucherRelationships": "[relationship CollectionObject.voucherRelationships]", - "yesNo1": "[literalField CollectionObject.yesNo1]", - "yesNo2": "[literalField CollectionObject.yesNo2]", - "yesNo3": "[literalField CollectionObject.yesNo3]", - "yesNo4": "[literalField CollectionObject.yesNo4]", - "yesNo5": "[literalField CollectionObject.yesNo5]", - "yesNo6": "[literalField CollectionObject.yesNo6]", - } - `)); + expect(tables.CollectionObject.field).toMatchSnapshot()); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts index 01c162c429e..1d674cda2a0 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -56,6 +56,7 @@ export class BusinessRuleManager { if (isTreeResource(this.resource as SpecifyResource)) initializeTreeRecord(this.resource as SpecifyResource); + // REFACTOR: use the 'changed' event over 'change' this.resource.on('change', this.changed, this); this.resource.on('add', this.added, this); this.resource.on('remove', this.removed, this); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collection.ts b/specifyweb/frontend/js_src/lib/components/DataModel/collection.ts index f6a2e2d3e4f..06010a7fbe3 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collection.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collection.ts @@ -10,6 +10,7 @@ import type { } from './helperTypes'; import { parseResourceUrl } from './resource'; import { serializeResource } from './serializers'; +import type { Collection } from './specifyTable'; import { genericTables, tables } from './tables'; import type { Tables } from './types'; @@ -23,14 +24,16 @@ export type CollectionFetchFilters = Partial< number > > & { - readonly limit: number; + readonly limit?: number; + readonly reset?: boolean; readonly offset?: number; - readonly domainFilter: boolean; + readonly domainFilter?: boolean; readonly orderBy?: | keyof CommonFields | keyof SCHEMA['fields'] | `-${string & keyof CommonFields}` | `-${string & keyof SCHEMA['fields']}`; + readonly success?: (collection: Collection) => void; }; export const DEFAULT_FETCH_LIMIT = 20; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts index f74ebf47166..8bc041d68a1 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.ts @@ -2,8 +2,13 @@ import _ from 'underscore'; +import { removeKey } from '../../utils/utils'; import { assert } from '../Errors/assert'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { Backbone } from './backbone'; +import { DEFAULT_FETCH_LIMIT } from './collection'; +import type { AnySchema } from './helperTypes'; +import type { SpecifyResource } from './legacyTypes'; // REFACTOR: remove @ts-nocheck @@ -14,14 +19,52 @@ const Base = Backbone.Collection.extend({ }, }); +export const isRelationshipCollection = (value: unknown): boolean => + value instanceof DependentCollection || + value instanceof IndependentCollection; + function notSupported() { throw new Error('method is not supported'); } -async function fakeFetch() { +async function fakeFetch(rawOptions) { + const options = { + ...rawOptions, + }; + if (typeof options.success === 'function') + options.success.call(options.context, this, undefined, options); return this; } +async function lazyFetch(options) { + assert(this instanceof LazyCollection); + const self = this; + if (this._fetch) return this._fetch; + if (this.related?.isNew()) return fakeFetch.call(this, options); + + this._neverFetched = false; + + options ||= {}; + + options.update ??= true; + options.remove ??= false; + options.silent = true; + assert(options.at == null); + + // REFACTOR: make passing filters directly to fetch easier + options.data = + options.data || _.extend({ domainfilter: this.domainfilter }, this.filters); + options.data.offset = options.offset ?? this.length; + options.data.orderby = options.orderby; + + _(options).has('limit') && (options.data.limit = options.limit); + this._fetch = Backbone.Collection.prototype.fetch.call(this, options); + return this._fetch.then(() => { + self._fetch = null; + return self; + }); +} + function setupToOne(collection, options) { collection.field = options.field; collection.related = options.related; @@ -44,20 +87,15 @@ export const DependentCollection = Base.extend({ Base.call(this, records, options); }, initialize(_tables, options) { + setupToOne(this, 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. @@ -80,7 +118,12 @@ export const DependentCollection = Base.extend({ isComplete() { return true; }, - fetch: fakeFetch, + getFetchOffset() { + return 0; + }, + async fetch(options) { + return fakeFetch.call(this, options); + }, sync: notSupported, create: notSupported, }); @@ -91,6 +134,7 @@ export const LazyCollection = Base.extend({ constructor(options = {}) { this.table = this.model; Base.call(this, null, options); + this._totalCount = undefined; this.filters = options.filters || {}; this.domainfilter = Boolean(options.domainfilter) && @@ -100,7 +144,7 @@ export const LazyCollection = Base.extend({ return `/api/specify/${this.model.specifyTable.name.toLowerCase()}/`; }, isComplete() { - return this.length === this._totalCount; + return !this._neverFetched && this.length === this._totalCount; }, parse(resp) { let objects; @@ -116,44 +160,133 @@ export const LazyCollection = Base.extend({ 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()) + 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; - }); + } + return lazyFetch.call(this, options); }, async fetchIfNotPopulated() { return this._neverFetched && this.related?.isNew() !== true ? this.fetch() : this; }, + getFetchOffset() { + return this.length; + }, getTotalCount() { if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); return this.fetchIfNotPopulated().then((_this) => _this._totalCount); }, }); +export const IndependentCollection = LazyCollection.extend({ + __name__: 'IndependentCollectionBase', + constructor(options) { + this.table = this.model; + Base.call(this, null, options); + this.filters = options.filters || {}; + this.domainfilter = + Boolean(options.domainfilter) && + this.model?.specifyTable.getScopingRelationship() !== undefined; + + this._totalCount = 0; + this.removed = new Set(); + this.updated = {}; + }, + initialize(_tables, options) { + setupToOne(this, options); + + this.on( + 'change', + function (resource: SpecifyResource) { + if (!resource.isBeingInitialized()) { + if (relationshipIsToMany(this.field)) { + const otherSideName = this.field.getReverse().name; + this.related.set(otherSideName, resource); + } + this.updated[resource.cid] = resource; + this.trigger('saverequired'); + } + }, + this + ); + + this.on( + 'add', + function (resource: SpecifyResource) { + if (resource.isNew()) { + this.updated[resource.cid] = resource; + } else { + this.removed.delete(resource.url()); + this.updated[resource.cid] = resource.url(); + } + this._totalCount += 1; + this.trigger('saverequired'); + }, + this + ); + + this.on( + 'remove', + function (resource: SpecifyResource) { + if (!resource.isNew() && resource.get(this.field.name) !== null) { + this.removed.add(resource.url()); + } + this.updated = removeKey(this.updated, resource.cid); + this._totalCount -= 1; + this.trigger('saverequired'); + }, + this + ); + + this.listenTo(this.related, 'saved', function () { + this.updated = {}; + this.removed = new Set(); + }); + }, + parse(resp) { + const self = this; + const records = Reflect.apply( + LazyCollection.prototype.parse, + this, + arguments + ); + + this._totalCount -= (this.removed as ReadonlySet).size; + + return records.filter( + ({ resource_uri }) => + !(this.removed as ReadonlySet).has(resource_uri) + ); + }, + async fetch(options) { + // If the related is being fetched, don't try and fetch the collection + if (this.related._fetch !== null) return fakeFetch.call(this, options); + + this.filters[this.field.name.toLowerCase()] = this.related.id; + + const newOptions = { + ...options, + update: options?.reset !== true, + offset: options?.offset ?? this.getFetchOffset(), + }; + + return lazyFetch.call(this, newOptions); + }, + getFetchOffset() { + return this.length === 0 && this.removed.size > 0 + ? this.removed.size + : Math.floor(this.length / DEFAULT_FETCH_LIMIT) * DEFAULT_FETCH_LIMIT; + }, + toApiJSON(options) { + return { + update: Object.values(this.updated), + remove: Array.from(this.removed), + }; + }, +}); + export const ToOneCollection = LazyCollection.extend({ __name__: 'LazyToOneCollectionBase', initialize(_models, options) { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts index 3a09c959b1f..bc7b53e976b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts @@ -1,5 +1,6 @@ import { f } from '../../utils/functools'; import type { RA, ValueOf } from '../../utils/types'; +import { caseInsensitiveHash } from '../../utils/utils'; import { isTreeResource } from '../InitialContext/treeRanks'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import type { @@ -40,76 +41,104 @@ const weekDayMap = { Thursday: 5, Friday: 6, Saturday: 7, -}; +} as const; + +const _backendFilters = (field: string, ...fieldTransforms: RA) => + ({ + equals: (value: number | string) => ({ + [[field, ...fieldTransforms, 'exact'].join(djangoLookupSeparator)]: value, + }), + contains: (value: string) => ({ + [[field, ...fieldTransforms, 'contains'].join(djangoLookupSeparator)]: + value, + }), + caseInsensitiveContains: (value: string) => ({ + [[field, ...fieldTransforms, 'icontains'].join(djangoLookupSeparator)]: + value, + }), + caseInsensitiveStartsWith: (value: string) => ({ + [[field, ...fieldTransforms, 'istartswith'].join(djangoLookupSeparator)]: + value, + }), + startsWith: (value: string) => ({ + [[field, ...fieldTransforms, 'startswith'].join(djangoLookupSeparator)]: + value, + }), + caseInsensitiveEndsWith: (value: string) => ({ + [[field, ...fieldTransforms, 'iendswith'].join(djangoLookupSeparator)]: + value, + }), + endsWith: (value: string) => ({ + [[field, ...fieldTransforms, 'endswith'].join(djangoLookupSeparator)]: + value, + }), + isIn: (value: RA) => ({ + [[field, ...fieldTransforms, 'in'].join(djangoLookupSeparator)]: + value.join(','), + }), + isNull: (value: 'false' | 'true' = 'true') => ({ + [[field, ...fieldTransforms, 'isnull'].join(djangoLookupSeparator)]: + value, + }), + greaterThan: (value: number) => ({ + [[field, ...fieldTransforms, 'gt'].join(djangoLookupSeparator)]: value, + }), + greaterThanOrEqualTo: (value: number) => ({ + [[field, ...fieldTransforms, 'gte'].join(djangoLookupSeparator)]: value, + }), + lessThan: (value: number) => ({ + [[field, ...fieldTransforms, 'lt'].join(djangoLookupSeparator)]: value, + }), + lessThanOrEqualTo: (value: number) => ({ + [[field, ...fieldTransforms, 'lte'].join(djangoLookupSeparator)]: value, + }), + matchesRegex: (value: string) => ({ + [[field, ...fieldTransforms, 'regex'].join(djangoLookupSeparator)]: + encodeURIComponent(value), + }), + + dayEquals: (value: number) => ({ + [[field, ...fieldTransforms, 'day'].join(djangoLookupSeparator)]: value, + }), + monthEquals: (value: number) => ({ + [[field, ...fieldTransforms, 'month'].join(djangoLookupSeparator)]: value, + }), + yearEquals: (value: number) => ({ + [[field, ...fieldTransforms, 'year'].join(djangoLookupSeparator)]: value, + }), + weekEquals: (value: number) => ({ + [[field, ...fieldTransforms, 'week'].join(djangoLookupSeparator)]: value, + }), + weekDayEquals: ( + value: ValueOf | keyof typeof weekDayMap + ) => ({ + [[field, ...fieldTransforms, 'week_day'].join(djangoLookupSeparator)]: + typeof value === 'number' + ? value + : caseInsensitiveHash(weekDayMap, value), + }), + } as const); /** * Use this to construct a query using a lookup for Django. * Returns an object which can be used as a filter when fetched from the backend. - * Example: backendFilter('number1').isIn([1, 2, 3]) is the equivalent - * of {number1__in: [1, 2, 3].join(',')} + * Example: + * ```ts + * backendFilter('number1').isIn([1, 2, 3]) + * // is the equivalent of + * {number1__in: [1, 2, 3].join(',')} + * + * // Filters can be negated using not + * backendFilter('text1').not.contains('someText') + * ``` + * * * See the Django docs at: * https://docs.djangoproject.com/en/3.2/ref/models/querysets/#field-lookups */ export const backendFilter = (field: string) => ({ - equals: (value: number | string) => ({ - [[field, 'exact'].join(djangoLookupSeparator)]: value, - }), - contains: (value: string) => ({ - [[field, 'contains'].join(djangoLookupSeparator)]: value, - }), - caseInsensitiveContains: (value: string) => ({ - [[field, 'icontains'].join(djangoLookupSeparator)]: value, - }), - caseInsensitiveStartsWith: (value: string) => ({ - [[field, 'istartswith'].join(djangoLookupSeparator)]: value, - }), - startsWith: (value: string) => ({ - [[field, 'startswith'].join(djangoLookupSeparator)]: value, - }), - caseInsensitiveEndsWith: (value: string) => ({ - [[field, 'iendswith'].join(djangoLookupSeparator)]: value, - }), - endsWith: (value: string) => ({ - [[field, 'endswith'].join(djangoLookupSeparator)]: value, - }), - isIn: (value: RA) => ({ - [[field, 'in'].join(djangoLookupSeparator)]: value.join(','), - }), - isNull: (value: 'false' | 'true' = 'true') => ({ - [[field, 'isnull'].join(djangoLookupSeparator)]: value, - }), - greaterThan: (value: number) => ({ - [[field, 'gt'].join(djangoLookupSeparator)]: value, - }), - greaterThanOrEqualTo: (value: number) => ({ - [[field, 'gte'].join(djangoLookupSeparator)]: value, - }), - lessThan: (value: number) => ({ - [[field, 'lt'].join(djangoLookupSeparator)]: value, - }), - lessThanOrEqualTo: (value: number) => ({ - [[field, 'lte'].join(djangoLookupSeparator)]: value, - }), - matchesRegex: (value: string) => ({ - [[field, 'regex'].join(djangoLookupSeparator)]: value, - }), - - dayEquals: (value: number) => ({ - [[field, 'day'].join(djangoLookupSeparator)]: value, - }), - monthEquals: (value: number) => ({ - [[field, 'lte'].join(djangoLookupSeparator)]: value, - }), - yearEquals: (value: number) => ({ - [[field, 'year'].join(djangoLookupSeparator)]: value, - }), - weekEquals: (value: number) => ({ - [[field, 'week'].join(djangoLookupSeparator)]: value, - }), - weekDayEquals: (value: keyof typeof weekDayMap) => ({ - [[field, 'week_day'].join(djangoLookupSeparator)]: weekDayMap[value], - }), + not: _backendFilters(field, 'not'), + ..._backendFilters(field), }); export const isResourceOfType = ( diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index bf0d9ee2289..13bb62f3826 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -4,6 +4,7 @@ import type { IR, RA } from '../../utils/types'; import type { BusinessRuleManager } from './businessRules'; +import type { CollectionFetchFilters } from './collection'; import type { AnySchema, CommonFields, @@ -113,7 +114,8 @@ export type SpecifyResource = { VALUE extends (SCHEMA['toManyDependent'] & SCHEMA['toManyIndependent'])[FIELD_NAME] >( - fieldName: FIELD_NAME + fieldName: FIELD_NAME, + filters?: CollectionFetchFilters ): Promise>; set< FIELD_NAME extends @@ -156,6 +158,10 @@ export type SpecifyResource = { ): SpecifyResource; // Not type safe bulkSet(value: IR): SpecifyResource; + // Unsafe + readonly independentResources: IR< + Collection | SpecifyResource | null | undefined + >; // Unsafe. Use getDependentResource instead whenever possible readonly dependentResources: IR< Collection | SpecifyResource | null | undefined diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts index 5126ca3e798..235711ae12d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resourceApi.ts @@ -7,9 +7,17 @@ import { Http } from '../../utils/ajax/definitions'; import { removeKey } from '../../utils/utils'; import { assert } from '../Errors/assert'; import { softFail } from '../Errors/Crash'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { Backbone } from './backbone'; import { attachBusinessRules } from './businessRules'; +import { isRelationshipCollection } from './collectionApi'; import { backboneFieldSeparator } from './helpers'; +import type { + AnySchema, + SerializedRecord, + SerializedResource, +} from './helperTypes'; +import type { SpecifyResource } from './legacyTypes'; import { getFieldsToNotClone, getResourceApiUrl, @@ -19,6 +27,9 @@ import { } from './resource'; import { initializeResource } from './scoping'; import { specialFields } from './serializers'; +import type { LiteralField, Relationship } from './specifyField'; +import type { Collection, SpecifyTable } from './specifyTable'; +import type { Tables } from './types'; // REFACTOR: remove @ts-nocheck @@ -29,7 +40,6 @@ function eventHandlerForToOne(related, field) { switch (event) { case 'saverequired': { this.handleChanged(); - this.trigger.apply(this, args); return; } case 'change:id': { @@ -51,7 +61,7 @@ function eventHandlerForToOne(related, field) { }; } -function eventHandlerForToMany(_related, field) { +function eventHandlerForToMany(related, field) { return function (event) { const args = _.toArray(arguments); switch (event) { @@ -61,14 +71,15 @@ function eventHandlerForToMany(_related, field) { } case 'saverequired': { this.handleChanged(); - this.trigger.apply(this, args); break; } + case 'change': case 'add': case 'remove': { // Annotate add and remove events with the field in which they occurred args[0] = `${event}:${field.name.toLowerCase()}`; this.trigger.apply(this, args); + Reflect.apply(this.trigger, this, ['change', this, related]); break; } } @@ -76,7 +87,15 @@ function eventHandlerForToMany(_related, field) { } // Always returns a resource -const maybeMakeResource = (value, relatedTable) => +const maybeMakeResource = < + TABLE extends SpecifyTable, + TABLE_SCHEMA extends Tables[TABLE['name']] +>( + value: + | Partial | SerializedResource> + | SpecifyResource, + relatedTable: TABLE +): SpecifyResource => value instanceof ResourceBase ? value : new relatedTable.Resource(value, { parse: true }); @@ -89,7 +108,7 @@ export const ResourceBase = Backbone.Model.extend({ _save: null, // Stores reference to the ajax deferred while the resource is being saved /** - * Returns true if the resource is being fetched and saved from Backbone + * Returns true if the resource is being fetched or saved from Backbone * More specifically, returns true while this resource holds a reference * to Backbone's save() and fetch() in _save and _fetch */ @@ -100,6 +119,7 @@ export const ResourceBase = Backbone.Model.extend({ constructor() { this.specifyTable = this.constructor.specifyTable; this.dependentResources = {}; // References to related objects referred to by field in this resource + this.independentResources = {}; Reflect.apply(Backbone.Model, this, arguments); // TEST: check if this is necessary }, initialize(attributes, options) { @@ -221,7 +241,10 @@ export const ResourceBase = Backbone.Model.extend({ // Case insensitive return Backbone.Model.prototype.get.call(this, attribute.toLowerCase()); }, - storeDependent(field, related) { + storeDependent( + field: Relationship, + related: Collection | SpecifyResource | null + ): void { assert(field.isDependent()); const setter = field.type === 'one-to-many' @@ -229,7 +252,7 @@ export const ResourceBase = Backbone.Model.extend({ : '_setDependentToOne'; this[setter](field, related); }, - _setDependentToOne(field, related) { + _setDependentToOne(field: Relationship, related) { const oldRelated = this.dependentResources[field.name.toLowerCase()]; if (!related) { if (oldRelated) { @@ -265,7 +288,7 @@ export const ResourceBase = Backbone.Model.extend({ } } }, - _setDependentToMany(field, toMany) { + _setDependentToMany(field: Relationship, toMany: Collection) { const oldToMany = this.dependentResources[field.name.toLowerCase()]; oldToMany && oldToMany.off('all', null, this); @@ -273,6 +296,61 @@ export const ResourceBase = Backbone.Model.extend({ this.dependentResources[field.name.toLowerCase()] = toMany; toMany.on('all', eventHandlerForToMany(toMany, field), this); }, + storeIndependent( + field: Relationship, + related: Collection | SpecifyResource | null + ) { + assert(!field.isDependent()); + + if (relationshipIsToMany(field)) + this._storeIndependentToMany(field, related); + else this._storeIndependentToOne(field, related); + }, + _storeIndependentToOne( + field: Relationship, + related: SpecifyResource | null + ) { + const oldRelated = this.independentResources[field.name.toLowerCase()]; + if (!related) { + if (oldRelated) { + oldRelated.off('all', null, this); + this.trigger('saverequired'); + } + this.independentResources[field.name.toLowerCase()] = null; + return; + } + + if (oldRelated && oldRelated.cid === related.cid) return; + + oldRelated && oldRelated.off('all', null, this); + + related.on('all', eventHandlerForToOne(related, field), this); + + switch (field.type) { + case 'one-to-one': + case 'many-to-one': { + this.independentResources[field.name.toLowerCase()] = related; + break; + } + case 'zero-to-one': { + this.independentResources[field.name.toLowerCase()] = related; + related.set(field.otherSideName, this.url()); + break; + } + default: { + throw new Error( + `storeIndependentToOne: unhandled field type: ${field.type} for ${this.specifyTable.name}.${field.name}` + ); + } + } + }, + _storeIndependentToMany(field: Relationship, toMany: Collection) { + const oldIndependent = this.independentResources[field.name.toLowerCase()]; + if (oldIndependent !== undefined) oldIndependent.off('all', null, this); + + this.independentResources[field.name.toLowerCase()] = toMany; + toMany.on('all', eventHandlerForToMany(toMany, field), this); + }, // Separate name to simplify typing bulkSet(attributes, options) { return this.set(attributes, options); @@ -393,7 +471,7 @@ export const ResourceBase = Backbone.Model.extend({ }, _handleInlineDataOrResource(value, fieldName) { // BUG: check type of value - const field = this.specifyTable.getField(fieldName); + const field: Relationship = this.specifyTable.strictGetField(fieldName); const relatedTable = field.relatedTable; // BUG: don't do anything for virtual fields @@ -409,15 +487,23 @@ export const ResourceBase = Backbone.Model.extend({ ); this.storeDependent(field, collection); } else { - console.warn( - 'got unexpected inline data for independent collection field', - { collection: this, field, value } + const collection = new relatedTable.IndependentCollection( + collectionOptions, + value ); + this.storeIndependent(field, collection); } // Because the foreign key is on the other side this.trigger(`change:${fieldName}`, this); this.trigger('change', this); + + /** + * These are serialized and added to the JSON before being sent to the + * server and are not in the resource's attributes + * + * https://backbonejs.org/#Sync + */ return undefined; } case 'many-to-one': { @@ -426,13 +512,14 @@ export const ResourceBase = Backbone.Model.extend({ * 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); + if (field.isDependent()) this.storeDependent(field, null); + else this.storeIndependent(field, null); return value; } const toOne = maybeMakeResource(value, relatedTable); - - field.isDependent() && this.storeDependent(field, toOne); + if (field.isDependent()) this.storeDependent(field, toOne); + else this.storeIndependent(field, toOne); this.trigger(`change:${fieldName}`, this); this.trigger('change', this); return toOne.url(); @@ -519,8 +606,12 @@ export const ResourceBase = Backbone.Model.extend({ ); }, // Duplicate definition for purposes of better typing: - async rgetCollection(fieldName) { - return this.getRelated(fieldName, { prePop: true }); + async rgetCollection(fieldName, rawOptions) { + const options = { + ...rawOptions, + prePop: true, + }; + return this.getRelated(fieldName, options); }, async getRelated(fieldName, options) { options ||= { @@ -544,14 +635,23 @@ export const ResourceBase = Backbone.Model.extend({ if (!value) return value; // Ok if the related resource doesn't exist else if (typeof value.fetchIfNotPopulated === 'function') return value.fetchIfNotPopulated(); + /* + * Relationship Collections have already been fetched through _rget. + * This is needed to prevent refetching the collection with the default + * limit of 20 + */ else if (isRelationshipCollection(value)) return value; else if (typeof value.fetch === 'function') return value.fetch(); } return value; }); }, - async _rget(path, options) { + async _rget( + path: RA, + options: OPTIONS + ) { let fieldName = path[0].toLowerCase(); - const field = this.specifyTable.getField(fieldName); + const field: LiteralField | Relationship | undefined = + this.specifyTable.getField(fieldName); field && (fieldName = field.name.toLowerCase()); // In case fieldName is an alias let value = this.get(fieldName); field || @@ -584,16 +684,21 @@ export const ResourceBase = Backbone.Model.extend({ // A foreign key field. if (!value) return value; // No related object - // Is the related resource cached? + // Is the related resource a cached dependent? let toOne = this.dependentResources[fieldName]; + if (!toOne) { _(value).isString() || softFail('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); + } else { + // Always store and refetch independent related resources + this.storeIndependent(field, toOne); } } // If we want a field within the related resource then recur @@ -604,41 +709,9 @@ export const ResourceBase = Backbone.Model.extend({ throw "can't traverse into a collection using dot notation"; } - // Is the collection cached? - let toMany = this.dependentResources[fieldName]; - if (!toMany) { - const 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'); - const temporaryCollection = new related.ToOneCollection( - collectionOptions - ); - return temporaryCollection - .fetch({ limit: 0 }) - .then( - () => - new related.DependentCollection( - collectionOptions, - temporaryCollection.tables - ) - ) - .then((toMany) => { - _this.storeDependent(field, toMany); - }); - } - } + return field.isDependent() + ? this.getDependentToMany(field, options) + : this.getIndependentToMany(field, options); } case 'zero-to-one': { /* @@ -680,7 +753,79 @@ export const ResourceBase = Backbone.Model.extend({ } } }, - save({ + async getDependentToMany( + field: Relationship, + filters + ): Promise> { + assert(field.isDependent()); + + const self = this; + const fieldName = field.name.toLowerCase(); + const relatedTable = field.relatedTable; + + const existingToMany: Collection | undefined = + this.dependentResources[fieldName]; + + const collectionOptions = { + field: field.getReverse(), + related: this, + }; + + if (!this.isNew() && existingToMany === undefined) + console.warn('expected dependent resource to be in cache'); + + const collection = + existingToMany === undefined + ? this.isNew() + ? new relatedTable.DependentCollection(collectionOptions, []) + : await new relatedTable.ToOneCollection(collectionOptions) + .fetch({ ...filters, limit: 0 }) + .then( + (collection) => + new relatedTable.DependentCollection( + collectionOptions, + collection.models + ) + ) + : existingToMany; + + await collection.fetch({ ...filters, limit: 0 }).then((collection) => { + self.storeDependent(field, collection); + }); + return this.getDependentResource(field.name); + }, + async getIndependentToMany( + field: Relationship, + filters + ): Promise> { + assert(!field.isDependent()); + + const fieldName = field.name.toLowerCase(); + const relatedTable = field.relatedTable; + + const existingToMany: Collection | undefined = + this.independentResources[fieldName]; + + const collectionOptions = { + field: field.getReverse(), + related: this, + }; + + const collection = + existingToMany === undefined + ? new relatedTable.IndependentCollection(collectionOptions) + : existingToMany; + + await collection.fetch({ + ...filters, + // Only store the collection if fetch is successful (doesn't return undefined) + success: (collection) => { + this.storeIndependent(field, collection); + }, + }); + return this.independentResources[field.name.toLowerCase()]; + }, + async save({ onSaveConflict: handleSaveConflict, errorOnAlreadySaving = true, } = {}) { @@ -736,16 +881,29 @@ export const ResourceBase = Backbone.Model.extend({ }, toJSON() { const self = this; - const json = Backbone.Model.prototype.toJSON.apply(self, arguments); + const options = arguments; + const json = Backbone.Model.prototype.toJSON.apply(self, options); _.each(self.dependentResources, (related, fieldName) => { const field = self.specifyTable.getField(fieldName); if (field.type === 'zero-to-one') { - json[fieldName] = related ? [related.toJSON()] : []; + json[fieldName] = related ? [related.toJSON(options)] : []; } else { - json[fieldName] = related ? related.toJSON() : null; + json[fieldName] = related ? related.toJSON(options) : null; } }); + + Object.entries(self.independentResources).forEach( + ([fieldName, related]) => { + if (related) { + json[fieldName] = isRelationshipCollection(related) + ? related.toApiJSON(options) + : related.isNew() || related.needsSaved + ? related.toJSON(options) + : related.url(); + } + } + ); if (typeof this.get('resource_uri') !== 'string') json._tableName = this.specifyTable.name; return json; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx index 117f0e085e0..ed6ce30ac50 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataModel/saveBlockers.tsx @@ -9,6 +9,7 @@ import { eventListener } from '../../utils/events'; import { f } from '../../utils/functools'; import type { GetOrSet, RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; +import type { SET } from '../../utils/utils'; import { removeItem } from '../../utils/utils'; import { softError } from '../Errors/assert'; import { softFail } from '../Errors/Crash'; @@ -111,7 +112,7 @@ export function useSaveBlockers( export function setSaveBlockers( resource: SpecifyResource, field: LiteralField | Relationship, - errors: Parameters>[1]>[0], + errors: Parameters>[typeof SET]>[0], blockerKey: string ): void { const resolvedErrors = @@ -200,28 +201,25 @@ const getAllBlockers = ( resources: [resource], })) ?? []), ...filterArray( - Object.entries(resource.dependentResources).flatMap( - ([fieldName, collectionOrResource]) => - (filterBlockers !== undefined && - fieldName.toLowerCase() !== filterBlockers?.name.toLowerCase()) || - collectionOrResource === undefined || - collectionOrResource === null - ? undefined - : (collectionOrResource instanceof ResourceBase - ? getAllBlockers( - collectionOrResource as SpecifyResource - ) - : (collectionOrResource as Collection).models.flatMap( - f.unary(getAllBlockers) - ) - ).map(({ field, resources, message }) => ({ - field: [ - resource.specifyTable.strictGetField(fieldName), - ...field, - ], - resources: [...resources, resource], - message, - })) + Object.entries({ + ...resource.dependentResources, + ...resource.independentResources, + }).flatMap(([fieldName, collectionOrResource]) => + (filterBlockers !== undefined && + fieldName.toLowerCase() !== filterBlockers?.name.toLowerCase()) || + collectionOrResource === undefined || + collectionOrResource === null + ? undefined + : (collectionOrResource instanceof ResourceBase + ? getAllBlockers(collectionOrResource as SpecifyResource) + : (collectionOrResource as Collection).models.flatMap( + f.unary(getAllBlockers) + ) + ).map(({ field, resources, message }) => ({ + field: [resource.specifyTable.strictGetField(fieldName), ...field], + resources: [...resources, resource], + message, + })) ) ), ]; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts index 046ffc44bd3..264ca5d0c0f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts @@ -83,6 +83,14 @@ export const schemaExtras: { indexed: false, unique: false, }), + new LiteralField(table, { + name: 'isMemberOfCOG', + required: false, + readOnly: true, + type: 'java.lang.Boolean', + indexed: false, + unique: false, + }), new LiteralField(table, { // TODO: LiteralField or Relationship? name: 'age', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/scoping.ts b/specifyweb/frontend/js_src/lib/components/DataModel/scoping.ts index 20279182832..304640ff3ec 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/scoping.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/scoping.ts @@ -1,6 +1,5 @@ import type { RA } from '../../utils/types'; import { takeBetween } from '../../utils/utils'; -import { raise } from '../Errors/Crash'; import { getCollectionPref } from '../InitialContext/remotePrefs'; import { getTablePermissions } from '../Permissions'; import { hasTablePermission } from '../Permissions/helpers'; @@ -10,11 +9,11 @@ import type { AnySchema } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { getResourceApiUrl, idFromUrl } from './resource'; import { schema } from './schema'; +import { serializeResource } from './serializers'; import type { Relationship } from './specifyField'; import type { SpecifyTable } from './specifyTable'; import { strictGetTable, tables } from './tables'; -import type { CollectionObject } from './types'; -import type { Tables } from './types'; +import type { CollectionObject, Tables } from './types'; /** * Some tasks to do after a new resource is created @@ -51,27 +50,26 @@ export function initializeResource(resource: SpecifyResource): void { getCollectionPref('CO_CREATE_PREP', schema.domainLevelIds.collection) && hasTablePermission('Preparation', 'create') && resource.createdBy !== 'clone' - ) - collectionObject - .rgetCollection('preparations') - .then((preparations) => { - if (preparations.models.length === 0) - preparations.add(new tables.Preparation.Resource()); - }) - .catch(raise); + ) { + const preps = collectionObject.getDependentResource('preparations') ?? []; + if (preps.length === 0) + collectionObject.set('preparations', [ + serializeResource(new tables.Preparation.Resource()), + ]); + } if ( getCollectionPref('CO_CREATE_DET', schema.domainLevelIds.collection) && hasTablePermission('Determination', 'create') && resource.createdBy !== 'clone' - ) - collectionObject - .rgetCollection('determinations') - .then((determinations) => { - if (determinations.models.length === 0) - determinations.add(new tables.Determination.Resource()); - }) - .catch(raise); + ) { + const determinations = + collectionObject.getDependentResource('determinations') ?? []; + if (determinations.length === 0) + collectionObject.set('determinations', [ + serializeResource(new tables.Determination.Resource()), + ]); + } } export function getDomainResource< diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts index 8ce8576e7a3..e7abde2361e 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts @@ -14,8 +14,10 @@ import { error } from '../Errors/assert'; import { attachmentView } from '../FormParse/webOnlyViews'; import { parentTableRelationship } from '../Forms/parentTables'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; +import type { CollectionFetchFilters } from './collection'; import { DependentCollection, + IndependentCollection, LazyCollection, ToOneCollection, } from './collectionApi'; @@ -73,13 +75,13 @@ type CollectionConstructor = new ( >; readonly domainfilter?: boolean; }, - tables?: RA> + initalResources?: RA> ) => UnFetchedCollection; export type UnFetchedCollection = { - readonly fetch: (filter?: { - readonly limit: number; - }) => Promise>; + readonly fetch: ( + filter?: CollectionFetchFilters + ) => Promise>; }; export type Collection = { @@ -87,9 +89,12 @@ export type Collection = { readonly related?: SpecifyResource; readonly _totalCount?: number; readonly models: RA>; + readonly length: number; readonly table: { readonly specifyTable: SpecifyTable; }; + readonly updated?: IR | string>; + readonly removed?: ReadonlySet; readonly constructor: CollectionConstructor; /* * Shorthand method signature is used to prevent @@ -99,12 +104,19 @@ export type Collection = { /* eslint-disable @typescript-eslint/method-signature-style */ isComplete(): boolean; getTotalCount(): Promise; + getFetchOffset(): number; + toApiJSON(): { + readonly update: RA | string>; + readonly remove: RA; + }; indexOf(resource: SpecifyResource): number; // eslint-disable-next-line @typescript-eslint/naming-convention toJSON>(): RA; add(resource: RA> | SpecifyResource): void; remove(resource: SpecifyResource): void; - fetch(filter?: { readonly limit: number }): Promise>; + fetch( + filters?: CollectionFetchFilters + ): Promise>; trigger(eventName: string): void; on(eventName: string, callback: (...args: RA) => void): void; once(eventName: string, callback: (...args: RA) => void): void; @@ -177,6 +189,8 @@ export class SpecifyTable { */ public readonly DependentCollection: CollectionConstructor; + public readonly IndependentCollection: CollectionConstructor; + /** * A Backbone collection for loading a collection of items of this type as a * backwards -to-one collection of some other resource. @@ -235,6 +249,11 @@ export class SpecifyTable { model: this.Resource, }); + this.IndependentCollection = IndependentCollection.extend({ + __name__: `${this.name}IndependentCollection`, + model: this.Resource, + }); + this.ToOneCollection = ToOneCollection.extend({ __name__: `${this.name}ToOneCollection`, model: this.Resource, diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx index 0f2e9a347dd..50b65875a9e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/COJODialog.tsx @@ -29,6 +29,7 @@ export function COJODialog({ tables.CollectionObject, tables.CollectionObjectGroup, ]; + // REFACTOR: use the useSearchDialog hook here const [state, setState] = React.useState<'Add' | 'Search' | undefined>( undefined ); diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index 29f1a732f02..cf293550063 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -1,11 +1,11 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; -import type { State } from 'typesafe-reducer'; import { useId } from '../../hooks/useId'; import { useInfiniteScroll } from '../../hooks/useInfiniteScroll'; import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; +import { f } from '../../utils/functools'; import type { IR, RA } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { Button } from '../Atoms/Button'; @@ -25,6 +25,7 @@ import { FormMeta } from '../FormMeta'; import type { FormCellDefinition, SubViewSortField } from '../FormParse/cells'; import { attachmentView } from '../FormParse/webOnlyViews'; import { SpecifyForm } from '../Forms/SpecifyForm'; +import { SubViewContext } from '../Forms/SubView'; import { propsToFormMode, useViewDefinition } from '../Forms/useViewDefinition'; import { loadingGif } from '../Molecules'; import { Dialog } from '../Molecules/Dialog'; @@ -32,7 +33,7 @@ import type { SortConfig } from '../Molecules/Sorting'; import { SortIndicator } from '../Molecules/Sorting'; import { hasTablePermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; -import { SearchDialog } from '../SearchDialog'; +import { useSearchDialog } from '../SearchDialog'; import { AttachmentPluginSkeleton } from '../SkeletonLoaders/AttachmentPlugin'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { COJODialog } from './COJODialog'; @@ -177,9 +178,6 @@ export function FormTable({ const [isExpanded, setExpandedRecords] = React.useState< IR >({}); - const [state, setState] = React.useState< - State<'MainState'> | State<'SearchState'> - >({ type: 'MainState' }); const [flexibleColumnWidth] = userPreferences.use( 'form', 'definition', @@ -202,13 +200,44 @@ export function FormTable({ const [maxHeight] = userPreferences.use('form', 'formTable', 'maxHeight'); + const { searchDialog, showSearchDialog } = useSearchDialog({ + forceCollection: undefined, + extraFilters: undefined, + table: relationship.relatedTable, + multiple: !isToOne, + onSelected: handleAddResources, + }); + + const subviewContext = React.useContext(SubViewContext); + const parentContext = React.useMemo( + () => subviewContext?.parentContext ?? [], + [subviewContext?.parentContext] + ); + + const renderedResourceId = React.useMemo( + () => + parentContext.length === 0 || relationship.isDependent() + ? undefined + : f.maybe( + parentContext.find( + ({ relationship: parentRelationship }) => + parentRelationship === relationship.getReverse() + ), + ({ parentResource: { id } }) => id + ), + [parentContext, relationship] + ); + const children = collapsedViewDefinition === undefined ? ( commonText.loading() ) : resources.length === 0 ? (

{formsText.noData()}

) : ( -
+
({ maxHeight: `${maxHeight}px`, }} viewDefinition={collapsedViewDefinition} - onScroll={handleScroll} >
({ verticalAlign="stretch" visible > - + + + ) : ( @@ -337,7 +373,10 @@ export function FormTable({
({ )}
- {displayViewButton && isExpanded[resource.cid] === true ? ( + {displayViewButton && + isExpanded[resource.cid] === true && + !resource.isNew() ? ( ({ (!resource.isNew() || hasTablePermission( relationship.relatedTable.name, - 'delete' + isDependent ? 'delete' : 'update' )) ? ( handleDelete(resource)} @@ -445,68 +488,50 @@ export function FormTable({
); - - const isCOJO = - relationship.relatedTable.name === 'CollectionObjectGroupJoin' && - relationship.name === 'children'; - - const addButton = isCOJO ? ( - - } - /> - ) : typeof handleAddResources === 'function' && - mode !== 'view' && - !disableAdding && - hasTablePermission( - relationship.relatedTable.name, - isDependent ? 'create' : 'read' - ) ? ( - { + const addButtons = + mode === 'view' || disableAdding ? undefined : relationship.relatedTable + .name === 'CollectionObjectGroupJoin' && + relationship.name === 'children' ? ( + + | undefined + } + /> + ) : typeof handleAddResources === 'function' ? ( + <> + {!isDependent && + hasTablePermission(relationship.relatedTable.name, 'read') ? ( + + ) : undefined} + {hasTablePermission(relationship.relatedTable.name, 'create') ? ( + { const resource = new relationship.relatedTable.Resource(); handleAddResources([resource]); - } - : (): void => - setState({ - type: 'SearchState', - }) - } - /> - ) : undefined; - + }} + /> + ) : undefined} + + ) : undefined; return dialog === false ? ( {preHeaderButtons} {header} - {addButton} + {addButtons} {children} - {state.type === 'SearchState' && - typeof handleAddResources === 'function' ? ( - setState({ type: 'MainState' })} - onSelected={handleAddResources} - /> - ) : undefined} + {searchDialog} ) : ( diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTableCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTableCollection.tsx index 8a662e7b417..f108d1b1b83 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTableCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTableCollection.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import type { CollectionFetchFilters } from '../DataModel/collection'; import { DependentCollection } from '../DataModel/collectionApi'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -12,6 +13,7 @@ export function FormTableCollection({ collection, onAdd: handleAdd, onDelete: handleDelete, + onFetchMore: handleFetch, ...props }: Omit< Parameters[0], @@ -21,13 +23,16 @@ export function FormTableCollection({ readonly onDelete: | ((resource: SpecifyResource, index: number) => void) | undefined; + readonly onFetchMore?: ( + filters?: CollectionFetchFilters + ) => Promise | undefined>; }): JSX.Element | null { const [records, setRecords] = React.useState(Array.from(collection.models)); React.useEffect( () => resourceOn( collection, - 'add remove sort', + 'add remove sort sync', () => setRecords(Array.from(collection.models)), true ), @@ -35,9 +40,11 @@ export function FormTableCollection({ ); const handleFetchMore = React.useCallback(async () => { - await collection.fetch(); + await (typeof handleFetch === 'function' + ? handleFetch() + : collection.fetch()); setRecords(Array.from(collection.models)); - }, [collection]); + }, [collection, handleFetch]); const isDependent = collection instanceof DependentCollection; const relationship = collection.field?.getReverse(); diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/viewSpec.ts b/specifyweb/frontend/js_src/lib/components/FormEditor/viewSpec.ts index 55d488e32af..9a4a6ac76e9 100644 --- a/specifyweb/frontend/js_src/lib/components/FormEditor/viewSpec.ts +++ b/specifyweb/frontend/js_src/lib/components/FormEditor/viewSpec.ts @@ -17,6 +17,7 @@ import { syncers } from '../Syncer/syncers'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; import { createSimpleXmlNode } from '../Syncer/xmlToJson'; import { createXmlSpec } from '../Syncer/xmlUtils'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; /* eslint-disable @typescript-eslint/no-magic-numbers */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -368,6 +369,14 @@ const subViewSpec = ( console.error('SubView can only be used to display a relationship'); return undefined; } + if (field !== undefined && field.getReverse() === undefined) { + console.error( + `No reverse relationship exists${ + relationshipIsToMany(field) ? '' : '. Use a querycbx instead' + }` + ); + return undefined; + } if (field?.type === 'many-to-many') { // ResourceApi does not support .rget() on a many-to-many console.warn('Many-to-many relationships are not supported'); @@ -799,8 +808,32 @@ const textAreaSpec = ( ), }); -const queryComboBoxSpec = f.store(() => +const queryComboBoxSpec = ( + _spec: SpecToJson>, + { + table, + }: { + readonly table: SpecifyTable | undefined; + } +) => createXmlSpec({ + field: pipe( + rawFieldSpec(table).field, + syncer( + ({ parsed, ...rest }) => { + if ( + parsed?.some( + (field) => field.isRelationship && relationshipIsToMany(field) + ) + ) + console.error( + 'Unable to render a to-many relationship as a querycbx. Use a Subview instead' + ); + return { parsed, ...rest }; + }, + (value) => value + ) + ), // Customize view name dialogViewName: syncers.xmlAttribute('initialize displayDlg', 'skip'), searchDialogViewName: syncers.xmlAttribute('initialize searchDlg', 'skip'), @@ -836,8 +869,7 @@ const queryComboBoxSpec = f.store(() => syncers.maybe(syncers.toBoolean), syncers.default(true) ), - }) -); + }); const checkBoxSpec = f.store(() => createXmlSpec({ diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts index 1f06902f7f8..cc723f79eda 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts @@ -24,6 +24,7 @@ import { getBooleanAttribute, getParsedAttribute, } from '../Syncer/xmlUtils'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import type { PluginDefinition } from './plugins'; import { parseUiPlugin } from './plugins'; @@ -222,6 +223,15 @@ const processFieldType: { if (fields === undefined) { console.error('Trying to render a query combobox without a field name'); return { type: 'Blank' }; + } else if ( + fields.some( + (field) => field.isRelationship && relationshipIsToMany(field) + ) + ) { + console.error( + 'Unable to render a to-many relationship as a querycbx. Use a Subview instead' + ); + return { type: 'Blank' }; } else if (fields.at(-1)?.isRelationship === true) { return { type: 'QueryComboBox', diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index 61a4276868f..e0e98c86643 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { State } from 'typesafe-reducer'; import { useSearchParameter } from '../../hooks/navigation'; import { useBooleanState } from '../../hooks/useBooleanState'; @@ -8,6 +9,7 @@ import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { ReadOnlyContext } from '../Core/Contexts'; +import type { CollectionFetchFilters } from '../DataModel/collection'; import { DependentCollection } from '../DataModel/collectionApi'; import type { AnyInteractionPreparation, @@ -23,6 +25,7 @@ import type { FormType } from '../FormParse'; import type { SubViewSortField } from '../FormParse/cells'; import { augmentMode, ResourceView } from '../Forms/ResourceView'; import { useFirstFocus } from '../Forms/SpecifyForm'; +import { SubViewContext } from '../Forms/SubView'; import type { InteractionWithPreps } from '../Interactions/helpers'; import { interactionPrepTables } from '../Interactions/helpers'; import { InteractionDialog } from '../Interactions/InteractionDialog'; @@ -44,6 +47,7 @@ export function IntegratedRecordSelector({ onClose: handleClose, onAdd: handleAdd, onDelete: handleDelete, + onFetch: handleFetch, isCollapsed: defaultCollapsed, ...rest }: Omit< @@ -55,6 +59,9 @@ export function IntegratedRecordSelector({ readonly viewName?: string; readonly urlParameter?: string; readonly onClose: () => void; + readonly onFetch?: ( + filters?: CollectionFetchFilters + ) => Promise | undefined>; readonly sortField: SubViewSortField | undefined; }): JSX.Element { const containerRef = React.useRef(null); @@ -73,6 +80,19 @@ export function IntegratedRecordSelector({ const [isCollapsed, _handleCollapsed, handleExpand, handleToggle] = useBooleanState(defaultCollapsed); + const [state, setState] = React.useState< + | State< + 'AddResourceState', + { + readonly resource: SpecifyResource; + readonly handleAdd: ( + resources: RA> + ) => void; + } + > + | State<'MainState'> + >({ type: 'MainState' }); + const blockers = useAllSaveBlockers(collection.related, relationship); const hasBlockers = blockers.length > 0; React.useEffect(() => { @@ -116,6 +136,26 @@ export function IntegratedRecordSelector({ const isAttachmentTable = collection.table.specifyTable.name.includes('Attachment'); + const subviewContext = React.useContext(SubViewContext); + const parentContext = React.useMemo( + () => subviewContext?.parentContext ?? [], + [subviewContext?.parentContext] + ); + + const renderedResourceId = React.useMemo( + () => + parentContext.length === 0 || relationship.isDependent() + ? undefined + : f.maybe( + parentContext.find( + ({ relationship: parentRelationship }) => + parentRelationship === relationship.getReverse() + ), + ({ parentResource: { id } }) => id + ), + [parentContext, relationship] + ); + const isCOJO = relationship.relatedTable.name === 'CollectionObjectGroupJoin' && relationship.name === 'children'; @@ -129,7 +169,7 @@ export function IntegratedRecordSelector({ collection={collection} defaultIndex={isToOne ? 0 : index} relationship={relationship} - onAdd={(resources) => { + onAdd={(resources): void => { if (isInteraction) { setInteractionResource(resources[0]); handleOpenDialog(); @@ -142,6 +182,7 @@ export function IntegratedRecordSelector({ if (isCollapsed) handleExpand(); handleDelete?.(...args); }} + onFetch={handleFetch} onSlide={(index): void => { handleExpand(); if (typeof urlParameter === 'string') setIndex(index.toString()); @@ -154,6 +195,7 @@ export function IntegratedRecordSelector({ resource, onAdd: handleAdd, onRemove: handleRemove, + showSearchDialog, isLoading, }): JSX.Element => ( <> @@ -173,95 +215,138 @@ export function IntegratedRecordSelector({ /> ) : undefined} {formType === 'form' ? ( - ( - <> - - {hasTablePermission( - relationship.relatedTable.name, - isDependent ? 'create' : 'read' - ) && typeof handleAdd === 'function' ? ( - isCOJO ? ( - - } - /> - ) : ( - + ( + <> + + {!isDependent && + hasTablePermission( + relationship.relatedTable.name, + 'read' + ) && + typeof handleAdd === 'function' ? ( + 0) || isTaxonTreeDefItemTable } + onClick={showSearchDialog} + /> + ) : undefined} + {hasTablePermission( + relationship.relatedTable.name, + 'create' + ) && typeof handleAdd === 'function' ? ( + isCOJO ? ( + + } + /> + ) : ( + 0) + } + onClick={(): void => { + const resource = + new collection.table.specifyTable.Resource(); + + if ( + isDependent || + viewName === relationship.relatedTable.view + ) { + focusFirstField(); + handleAdd([resource]); + return; + } + + if (state.type === 'AddResourceState') + setState({ type: 'MainState' }); + else + setState({ + type: 'AddResourceState', + resource, + handleAdd, + }); + }} + /> + ) + ) : undefined} + {hasTablePermission( + relationship.relatedTable.name, + isDependent ? 'delete' : 'read' + ) && typeof handleRemove === 'function' ? ( + { - focusFirstField(); - const resource = - new collection.table.specifyTable.Resource(); - handleAdd([resource]); + handleRemove('minusButton'); }} /> - ) - ) : undefined} - {hasTablePermission( - relationship.relatedTable.name, - isDependent ? 'delete' : 'read' - ) && typeof handleRemove === 'function' ? ( - { - handleRemove('minusButton'); - }} + ) : undefined} + - ) : undefined} - - {isAttachmentTable && ( - - )} - {specifyNetworkBadge} - {!isToOne && slider} - - )} - isCollapsed={isCollapsed} - isDependent={isDependent} - isLoading={isLoading} - isSubForm={dialog === false} - key={resource?.cid} - preHeaderButtons={collapsibleButton} - resource={resource} - title={relationship.label} - onAdd={undefined} - onDeleted={ - collection.models.length <= 1 ? handleClose : undefined - } - onSaved={handleClose} - viewName={viewName} - /* - * Don't save the resource on save button click if it is a dependent - * resource - */ - onClose={handleClose} - /> + {isAttachmentTable && ( + + )} + {specifyNetworkBadge} + {!isToOne && slider} + + )} + isCollapsed={isCollapsed} + isDependent={isDependent} + isLoading={isLoading} + isSubForm={dialog === false} + key={resource?.cid} + preHeaderButtons={collapsibleButton} + resource={resource} + title={relationship.label} + onAdd={undefined} + onDeleted={ + collection.models.length <= 1 ? handleClose : undefined + } + onSaved={handleClose} + viewName={viewName} + /* + * Don't save the resource on save button click if it is a dependent + * resource + */ + onClose={handleClose} + /> +
) : null} {formType === 'formTable' ? ( ) : null} {dialogs} + {state.type === 'AddResourceState' && + typeof handleAdd === 'function' ? ( + setState({ type: 'MainState' })} + onDeleted={undefined} + onSaved={(): void => { + state.handleAdd([state.resource]); + setState({ type: 'MainState' }); + }} + /> + ) : null} )} diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx index 08e845d3e23..7f870629ed3 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelector.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type { State } from 'typesafe-reducer'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; @@ -8,7 +7,8 @@ import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { Relationship } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; -import { SearchDialog } from '../SearchDialog'; +import { useSearchDialog } from '../SearchDialog'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { Slider } from './Slider'; export type RecordSelectorProps = { @@ -56,6 +56,7 @@ export type RecordSelectorState = { readonly onRemove: | ((source: 'deleteButton' | 'minusButton') => void) | undefined; + readonly showSearchDialog: () => void; // True while fetching new record readonly isLoading: boolean; }; @@ -82,9 +83,33 @@ export function useRecordSelector({ [index] ); - const [state, setState] = React.useState< - State<'AddBySearch'> | State<'Main'> - >({ type: 'Main' }); + const isToOne = !relationshipIsToMany(field) || field?.type === 'zero-to-one'; + + const handleResourcesSelected = React.useMemo( + () => + typeof handleAdded === 'function' + ? (resources: RA>): void => { + if (field?.isDependent() ?? true) + f.maybe(field?.otherSideName, (fieldName) => + f.maybe(relatedResource?.url(), (url) => + resources.forEach((resource) => { + resource.set(fieldName, url as never); + }) + ) + ); + handleAdded(resources); + } + : undefined, + [handleAdded, relatedResource, field] + ); + + const { searchDialog, showSearchDialog } = useSearchDialog({ + extraFilters: undefined, + forceCollection: undefined, + multiple: !isToOne, + table, + onSelected: handleResourcesSelected, + }); return { slider: ( @@ -94,7 +119,7 @@ export function useRecordSelector({ onChange={ handleSlide === undefined ? undefined - : (index) => handleSlide?.(index, false) + : (index): void => handleSlide?.(index, false) } /> ), @@ -103,26 +128,7 @@ export function useRecordSelector({ isLoading: records[index] === undefined && totalCount !== 0, // While new resource is loading, display previous resource resource: records[index] ?? records[lastIndexRef.current], - dialogs: - state.type === 'AddBySearch' && typeof handleAdded === 'function' ? ( - setState({ type: 'Main' })} - onSelected={(resources): void => { - f.maybe(field?.otherSideName, (fieldName) => - f.maybe(relatedResource?.url(), (url) => - resources.forEach((resource) => - resource.set(fieldName, url as never) - ) - ) - ); - handleAdded(resources); - }} - /> - ) : null, + dialogs: searchDialog, onAdd: typeof handleAdded === 'function' ? (resources: RA>): void => { @@ -130,11 +136,12 @@ export function useRecordSelector({ const resource = resources[0]; if ( typeof field?.otherSideName === 'string' && + field.isDependent() && !relatedResource.isNew() ) resource.set(field.otherSideName, relatedResource.url() as any); handleAdded([resource]); - } else setState({ type: 'AddBySearch' }); + } else showSearchDialog(); } : undefined, onRemove: @@ -156,5 +163,6 @@ export function useRecordSelector({ ) : undefined : undefined, + showSearchDialog, }; } diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx index 76485597dfd..d28d38bd796 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/RecordSelectorFromCollection.tsx @@ -3,8 +3,10 @@ import React from 'react'; import { useTriggerState } from '../../hooks/useTriggerState'; import type { RA } from '../../utils/types'; import { defined } from '../../utils/types'; +import type { CollectionFetchFilters } from '../DataModel/collection'; import { DependentCollection, + isRelationshipCollection, LazyCollection, } from '../DataModel/collectionApi'; import type { AnySchema } from '../DataModel/helperTypes'; @@ -12,7 +14,6 @@ import type { SpecifyResource } from '../DataModel/legacyTypes'; import { resourceOn } from '../DataModel/resource'; import type { Relationship } from '../DataModel/specifyField'; import type { Collection } from '../DataModel/specifyTable'; -import { raise } from '../Errors/Crash'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import type { RecordSelectorProps, @@ -26,6 +27,7 @@ export function RecordSelectorFromCollection({ onAdd: handleAdd, onDelete: handleDelete, onSlide: handleSlide, + onFetch: handleFetch, children, defaultIndex = 0, ...rest @@ -44,6 +46,7 @@ export function RecordSelectorFromCollection({ readonly relationship: Relationship; readonly defaultIndex?: number; readonly children: (state: RecordSelectorState) => JSX.Element; + readonly onFetch?: (filters?: CollectionFetchFilters) => void; }): JSX.Element | null { const getRecords = React.useCallback( (): RA | undefined> => @@ -63,7 +66,7 @@ export function RecordSelectorFromCollection({ () => resourceOn( collection, - 'add remove destroy', + 'add remove destroy sync', (): void => setRecords(getRecords), true ), @@ -79,23 +82,24 @@ export function RecordSelectorFromCollection({ * don't need to fetch all records in between) */ if ( + typeof handleFetch === 'function' && + !isToOne && isLazy && collection.related?.isNew() !== true && - !collection.isComplete() && collection.models[index] === undefined ) - collection - .fetch() - .then(() => setRecords(getRecords)) - .catch(raise); - }, [collection, isLazy, getRecords, index, records.length]); + handleFetch(); + }, [collection, isLazy, index, records.length, isToOne, handleFetch]); const state = useRecordSelector({ ...rest, index, table: collection.table.specifyTable, + field: relationship, records, - relatedResource: isDependent ? collection.related : undefined, + relatedResource: isRelationshipCollection(collection) + ? collection.related + : undefined, totalCount: collection._totalCount ?? records.length, onAdd: (rawResources): void => { const resources = isToOne ? rawResources.slice(0, 1) : rawResources; diff --git a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx index 3d0a7c8bf4f..f6706b4e45f 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/DeleteButton.tsx @@ -17,6 +17,7 @@ import { icons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { resourceOn } from '../DataModel/resource'; import { serializeResource } from '../DataModel/serializers'; import type { Relationship } from '../DataModel/specifyField'; import { strictGetTable } from '../DataModel/tables'; @@ -71,6 +72,19 @@ export function DeleteButton({ false ); + React.useEffect( + () => + deferred + ? undefined + : resourceOn( + resource, + 'saved', + () => void fetchBlockers(resource).then(setBlockers), + false + ), + [resource, deferred] + ); + const [isOpen, handleOpen, handleClose] = useBooleanState(); const loading = React.useContext(LoadingContext); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx index 7fee9fe0d06..163ed7cb474 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/SubView.tsx @@ -2,38 +2,52 @@ import React from 'react'; import { usePromise } from '../../hooks/useAsyncState'; import { useBooleanState } from '../../hooks/useBooleanState'; +import { useCollection } from '../../hooks/useCollection'; import { useTriggerState } from '../../hooks/useTriggerState'; import { commonText } from '../../localization/common'; -import { overwriteReadOnly } from '../../utils/types'; -import { sortFunction } from '../../utils/utils'; +import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; +import { DataEntry } from '../Atoms/DataEntry'; import { attachmentSettingsPromise } from '../Attachments/attachments'; import { attachmentRelatedTables } from '../Attachments/utils'; import { ReadOnlyContext } from '../Core/Contexts'; +import type { CollectionFetchFilters } from '../DataModel/collection'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { resourceOn } from '../DataModel/resource'; import type { Relationship } from '../DataModel/specifyField'; -import type { Collection } from '../DataModel/specifyTable'; -import { raise, softFail } from '../Errors/Crash'; import type { FormType } from '../FormParse'; import type { SubViewSortField } from '../FormParse/cells'; import { IntegratedRecordSelector } from '../FormSliders/IntegratedRecordSelector'; +import { isTreeTable } from '../InitialContext/treeRanks'; import { TableIcon } from '../Molecules/TableIcon'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; -export const SubViewContext = React.createContext< +type SubViewContextType = | { readonly relationship: Relationship | undefined; readonly formType: FormType; readonly sortField: SubViewSortField | undefined; + /** + * Don't render a relationship if it is already being rendered in a + * parent subview. + * Avoids infinite cycles in rendering forms + */ + readonly parentContext: + | RA<{ + readonly relationship: Relationship; + readonly parentResource: SpecifyResource; + }> + | undefined; readonly handleChangeFormType: (formType: FormType) => void; readonly handleChangeSortField: ( sortField: SubViewSortField | undefined ) => void; } - | undefined ->(undefined); + | undefined; + +export const SubViewContext = + React.createContext(undefined); SubViewContext.displayName = 'SubViewContext'; export function SubView({ @@ -58,136 +72,58 @@ export function SubView({ readonly isCollapsed?: boolean; }): JSX.Element { const [sortField, setSortField] = useTriggerState(initialSortField); - - const fetchCollection = React.useCallback( - async function fetchCollection(): Promise< - Collection | undefined - > { - if ( - relationshipIsToMany(relationship) && - relationship.type !== 'zero-to-one' - ) - return parentResource - .rgetCollection(relationship.name) - .then((collection) => { - // TEST: check if this can ever happen - if (collection === null || collection === undefined) - return new relationship.relatedTable.DependentCollection({ - related: parentResource, - field: relationship.getReverse(), - }) as Collection; - if (sortField === undefined) return collection; - // BUG: this does not look into related tables - const field = sortField.fieldNames[0]; - // Overwriting the tables on the collection - overwriteReadOnly( - collection, - 'models', - Array.from(collection.models).sort( - sortFunction( - (resource) => resource.get(field), - sortField.direction === 'desc' - ) - ) - ); - return collection; - }); - else { - /** - * If relationship is -to-one, create a collection for the related - * resource. This allows to reuse most of the code from the -to-many - * relationships. RecordSelector handles collections with -to-one - * related field by removing the "+" button after first record is added - * and not rendering record count or record slider. - */ - const resource = await parentResource.rgetPromise(relationship.name); - const reverse = relationship.getReverse(); - if (reverse === undefined) { - softFail( - new Error( - `Can't render a SubView for ` + - `${relationship.table.name}.${relationship.name} because ` + - `reverse relationship does not exist` - ) - ); - return undefined; - } - const collection = ( - relationship.isDependent() - ? new relationship.relatedTable.DependentCollection({ - related: parentResource, - field: reverse, - }) - : new relationship.relatedTable.LazyCollection({ - filters: { - [reverse.name]: parentResource.id, - }, - }) - ) as Collection; - if (relationship.isDependent() && parentResource.isNew()) - // Prevent fetching related for newly created parent - overwriteReadOnly(collection, '_totalCount', 0); - - if (typeof resource === 'object' && resource !== null) - collection.add(resource); - overwriteReadOnly( - collection, - 'related', - collection.related ?? parentResource - ); - overwriteReadOnly( - collection, - 'field', - collection.field ?? relationship.getReverse() - ); - return collection; - } - }, - [parentResource, relationship, sortField] + const subviewContext = React.useContext(SubViewContext); + const parentContext = React.useMemo( + () => subviewContext?.parentContext ?? [], + [subviewContext?.parentContext] ); - const [collection, setCollection] = React.useState< - Collection | undefined - >(undefined); - const versionRef = React.useRef(0); + const [collection, _setCollection, handleFetch] = useCollection({ + parentResource, + relationship, + sortBy: sortField, + }); + React.useEffect( () => resourceOn( parentResource, - `change:${relationship.name}`, + 'saved', (): void => { - versionRef.current += 1; - const localVersionRef = versionRef.current; - fetchCollection() - .then((collection) => - /* - * If value changed since begun fetching, don't update the - * collection to prevent a race condition. - * REFACTOR: simplify this - */ - versionRef.current === localVersionRef - ? setCollection(collection) - : undefined - ) - .catch(raise); + handleFetch({ + offset: 0, + reset: true, + } as CollectionFetchFilters); }, - true + false ), - [parentResource, relationship, fetchCollection] + [parentResource, relationship, handleFetch] ); const [formType, setFormType] = useTriggerState(initialFormType); - const contextValue = React.useMemo( + + const contextValue = React.useMemo( () => ({ relationship, formType, sortField, + parentContext: [...parentContext, { relationship, parentResource }], handleChangeFormType: setFormType, handleChangeSortField: setSortField, }), - [relationship, formType, sortField, setFormType, setSortField] + [ + relationship, + formType, + sortField, + parentContext, + parentResource, + setFormType, + setSortField, + ] ); + const isReadOnly = React.useContext(ReadOnlyContext); + const [isOpen, _, handleClose, handleToggle] = useBooleanState(!isButton); const [isAttachmentConfigured] = usePromise(attachmentSettingsPromise, true); @@ -199,14 +135,17 @@ export function SubView({ const isAttachmentMisconfigured = isAttachmentTable && !isAttachmentConfigured; - const isReadOnly = React.useContext(ReadOnlyContext); return ( - {isButton && ( - relationship) + .includes(relationship) || collection === false ? undefined : ( + <> + {isButton && ( + 0 @@ -214,62 +153,93 @@ export function SubView({ : '' } ${isOpen ? '!bg-brand-300 dark:!bg-brand-500' : ''}`} - title={relationship.label} - onClick={handleToggle} - > - { - /* - * Attachment table icons have lots of vertical white space, making - * them look overly small on the forms. - * See https://github.com/specify/specify7/issues/1259 - * Thus, have to introduce some inconsistency here - */ - parentFormType === 'form' && ( - - ) - } - - {collection?.models.length ?? commonText.loading()} - - + title={relationship.label} + onClick={handleToggle} + > + { + /* + * Attachment table icons have lots of vertical white space, making + * them look overly small on the forms. + * See https://github.com/specify/specify7/issues/1259 + * Thus, have to introduce some inconsistency here + */ + parentFormType === 'form' && ( + + ) + } + + {collection?.models.length ?? commonText.loading()} + + + )} + {typeof collection === 'object' && isOpen ? ( + + + void parentResource.set( + relationship.name, + resource as never + ) + } + onClose={handleClose} + onDelete={ + relationshipIsToMany(relationship) && + relationship.type !== 'zero-to-one' + ? undefined + : (): void => + void parentResource.set( + relationship.name, + null as never + ) + } + onFetch={handleFetch} + /> + + ) : isButton ? undefined : ( + + + + {relationship.label} + + + {commonText.loading()} + + )} + )} - {typeof collection === 'object' && isOpen ? ( - - - void parentResource.set( - relationship.name, - resource as never - ) - } - onClose={handleClose} - onDelete={ - relationshipIsToMany(relationship) && - relationship.type !== 'zero-to-one' - ? undefined - : (): void => - void parentResource.set(relationship.name, null as never) - } - /> - - ) : undefined} ); } diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx index 8d8f7969c1d..db97a422527 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/index.tsx @@ -536,7 +536,7 @@ export function QueryComboBox({ } /> )} - {hasViewButton && hasTablePermission(relatedTable.name, 'create') + {hasViewButton && hasTablePermission(relatedTable.name, 'read') ? viewButton : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx index 96da3cb751f..bbb48929f9e 100644 --- a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; +import type { State } from 'typesafe-reducer'; import { useBooleanState } from '../../hooks/useBooleanState'; import { useId } from '../../hooks/useId'; @@ -49,10 +50,7 @@ const viewNameExceptions: Partial> = { GeologicTimePeriod: 'ChronosStratSearch', }; -/** - * Display a resource search dialog - */ -export function SearchDialog(props: { +type SearchDialogProps = { readonly forceCollection: number | undefined; readonly extraFilters: RA> | undefined; readonly table: SpecifyTable; @@ -61,7 +59,14 @@ export function SearchDialog(props: { readonly searchView?: string; readonly onSelected: (resources: RA>) => void; readonly onlyUseQueryBuilder?: boolean; -}): JSX.Element | null { +}; + +/** + * Display a resource search dialog + */ +export function SearchDialog( + props: SearchDialogProps +): JSX.Element | null { const [alwaysUseQueryBuilder] = userPreferences.use( 'form', 'queryComboBox', @@ -84,6 +89,41 @@ export function SearchDialog(props: { ); } +/** + * Displays a SearchDialog whenever `showSearchDialog` is invoked + */ +export function useSearchDialog({ + onSelected: handleSelected, + onClose: handleClosed, + ...rest +}: Omit, 'onClose' | 'onSelected'> & + Partial, 'onClose' | 'onSelected'>>): { + readonly searchDialog: JSX.Element | null; + readonly showSearchDialog: () => void; +} { + const [state, setState] = React.useState | State<'Search'>>({ + type: 'Main', + }); + + return { + searchDialog: + state.type === 'Search' && typeof handleSelected === 'function' ? ( + { + handleClosed?.(); + setState({ type: 'Main' }); + }} + onSelected={handleSelected} + /> + ) : null, + showSearchDialog: () => + typeof handleSelected === 'function' + ? setState({ type: 'Search' }) + : undefined, + }; +} + const filterResults = ( results: RA>, extraFilters: RA> diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx index 6272bb7090b..04478d71858 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/useDisambiguationDialog.tsx @@ -6,6 +6,7 @@ import { commonText } from '../../localization/common'; import { wbText } from '../../localization/workbench'; import { type RA } from '../../utils/types'; import { LoadingContext } from '../Core/Contexts'; +import { backendFilter } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { Collection } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; @@ -76,7 +77,7 @@ export function useDisambiguationDialog({ ); const table = strictGetTable(tableName); const resources = new table.LazyCollection({ - filters: { id__in: matches.ids.join(',') }, + filters: backendFilter('id').isIn(matches.ids), }) as Collection; loading( diff --git a/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx index 1e492a3f446..4466a47e8f9 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useCollection.tsx @@ -1,89 +1,173 @@ import React from 'react'; -import type { SerializedCollection } from '../components/DataModel/collection'; +import type { CollectionFetchFilters } from '../components/DataModel/collection'; import type { AnySchema } from '../components/DataModel/helperTypes'; -import { f } from '../utils/functools'; +import type { SpecifyResource } from '../components/DataModel/legacyTypes'; +import type { Relationship } from '../components/DataModel/specifyField'; +import type { Collection } from '../components/DataModel/specifyTable'; +import type { SubViewSortField } from '../components/FormParse/cells'; +import { relationshipIsToMany } from '../components/WbPlanView/mappingHelpers'; import type { GetOrSet } from '../utils/types'; -import { defined } from '../utils/types'; +import { overwriteReadOnly } from '../utils/types'; +import { sortFunction } from '../utils/utils'; import { useAsyncState } from './useAsyncState'; -/** - * A hook for fetching a collection of resources in a paginated way - */ -export function useCollection( - fetch: (offset: number) => Promise> -): readonly [ - SerializedCollection | undefined, - GetOrSet | undefined>[1], - () => Promise +type UseCollectionProps = { + readonly parentResource: SpecifyResource; + readonly relationship: Relationship; + readonly sortBy?: SubViewSortField; + readonly filters?: CollectionFetchFilters; +}; + +export function useCollection({ + parentResource, + relationship, + sortBy, +}: Omit, 'filters'>): readonly [ + ...GetOrSet | false | undefined>, + ( + filters?: CollectionFetchFilters + ) => Promise | undefined> ] { - const fetchRef = React.useRef< - Promise | undefined> | undefined - >(undefined); + const [collection, setCollection] = useAsyncState< + Collection | false | undefined + >( + React.useCallback( + async () => + relationshipIsToMany(relationship) && + relationship.type !== 'zero-to-one' + ? fetchToManyCollection({ + parentResource, + relationship, + sortBy, + }) + : fetchToOneCollection({ + parentResource, + relationship, + }), + [relationship, parentResource, sortBy] + ), + false + ); - const callback = React.useCallback(async () => { - if (typeof fetchRef.current === 'object') - return fetchRef.current.then(f.undefined); - if ( - collectionRef.current !== undefined && - collectionRef.current?.records.length === - collectionRef.current?.totalCount - ) - return undefined; - fetchRef.current = fetch(collectionRef.current?.records.length ?? 0).then( - (data) => { - fetchRef.current = undefined; - return data; - } - ); - return fetchRef.current; - }, [fetch]); + const versionRef = React.useRef(0); - const currentCallback = React.useRef(f.void); + const handleFetch = React.useCallback( + async ( + filters?: CollectionFetchFilters + ): Promise | undefined> => { + if (typeof collection !== 'object') return undefined; - const [collection, setCollection] = useAsyncState( - React.useCallback(async () => { - currentCallback.current = callback; - fetchRef.current = undefined; - collectionRef.current = undefined; - return callback(); - }, [callback]), - false - ); - const collectionRef = React.useRef< - SerializedCollection | undefined - >(); - collectionRef.current = collection; + versionRef.current += 1; + const localVersionRef = versionRef.current; + + const fetchCollection = + relationshipIsToMany(relationship) && + relationship.type !== 'zero-to-one' + ? fetchToManyCollection({ + parentResource, + relationship, + sortBy, + filters, + }) + : fetchToOneCollection({ parentResource, relationship, filters }); - const fetchMore = React.useCallback( - async () => - /* - * Ignore calls to fetchMore before collection is fetched for the first - * time - */ - currentCallback.current === callback - ? typeof fetchRef.current === 'object' - ? callback().then(f.undefined) - : callback().then((result) => - result !== undefined && - result.records.length > 0 && - // If the fetch function changed while fetching, discard the results - currentCallback.current === callback - ? setCollection((collection) => ({ - records: [ - ...defined( - collection, - 'Try to fetch more before collection is fetch.' - ).records, - ...result.records, - ], - totalCount: defined(collection).totalCount, - })) - : undefined - ) - : undefined, - [callback, collection] + return fetchCollection.then((collection) => { + if ( + typeof collection === 'object' && + versionRef.current === localVersionRef + ) { + setCollection(collection); + } + return collection === false ? undefined : collection; + }); + }, + [collection, parentResource, relationship, setCollection, sortBy] ); + return [collection, setCollection, handleFetch]; +} - return [collection, setCollection, fetchMore] as const; +const fetchToManyCollection = async ({ + parentResource, + relationship, + sortBy, + filters, +}: UseCollectionProps): Promise | undefined> => + parentResource + .rgetCollection(relationship.name, filters) + .then((collection) => { + // TEST: check if this can ever happen + if (collection === null || collection === undefined) + return new relationship.relatedTable.DependentCollection({ + related: parentResource, + field: relationship.getReverse(), + }) as Collection; + if (sortBy === undefined) return collection; + + // BUG: this does not look into related tables + const field = sortBy.fieldNames[0]; + + // Overwriting the models on the collection + overwriteReadOnly( + collection, + 'models', + Array.from(collection.models).sort( + sortFunction( + (resource) => resource.get(field), + sortBy.direction === 'desc' + ) + ) + ); + return collection; + }); + +async function fetchToOneCollection({ + parentResource, + relationship, + filters, +}: UseCollectionProps): Promise< + Collection | false | undefined +> { + /** + * If relationship is -to-one, create a collection for the related + * resource. This allows to reuse most of the code from -to-many + * relationships in components like Subview and RecordSelectorFromCollection + */ + /** + * REFACTOR: Allow passing filters to rgetPromise + * or at least handle refetching of independent when rgetPromise is called + */ + const resource = await parentResource.rgetPromise(relationship.name); + const reverse = relationship.getReverse(); + if (reverse === undefined) return false; + const collection = ( + relationship.isDependent() + ? new relationship.relatedTable.DependentCollection({ + related: parentResource, + field: reverse, + }) + : new relationship.relatedTable.IndependentCollection({ + related: parentResource, + field: reverse, + }) + ) as Collection; + if (relationship.isDependent() && parentResource.isNew()) + // Prevent fetching related for newly created parent + overwriteReadOnly(collection, '_totalCount', 0); + + if (typeof resource === 'object' && resource !== null) + collection.add(resource); + overwriteReadOnly( + collection, + 'related', + collection.related ?? parentResource + ); + overwriteReadOnly( + collection, + 'field', + collection.field ?? relationship.getReverse() + ); + return typeof filters === 'object' + ? collection.fetch(filters as CollectionFetchFilters) + : collection; } diff --git a/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx new file mode 100644 index 00000000000..1cc18d7c584 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import type { SerializedCollection } from '../components/DataModel/collection'; +import type { AnySchema } from '../components/DataModel/helperTypes'; +import { f } from '../utils/functools'; +import type { GetOrSet } from '../utils/types'; +import { defined } from '../utils/types'; +import { useAsyncState } from './useAsyncState'; + +/** + * A hook for fetching a collection of resources in a paginated way + */ +export function useSerializedCollection( + fetch: (offset: number) => Promise> +): readonly [ + SerializedCollection | undefined, + GetOrSet | undefined>[1], + () => Promise +] { + const fetchRef = React.useRef< + Promise | undefined> | undefined + >(undefined); + + const callback = React.useCallback(async () => { + if (typeof fetchRef.current === 'object') + return fetchRef.current.then(f.undefined); + if ( + collectionRef.current !== undefined && + collectionRef.current?.records.length === + collectionRef.current?.totalCount + ) + return undefined; + fetchRef.current = fetch(collectionRef.current?.records.length ?? 0).then( + (data) => { + fetchRef.current = undefined; + return data; + } + ); + return fetchRef.current; + }, [fetch]); + + const currentCallback = React.useRef(f.void); + + const [collection, setCollection] = useAsyncState( + React.useCallback(async () => { + currentCallback.current = callback; + fetchRef.current = undefined; + collectionRef.current = undefined; + return callback(); + }, [callback]), + false + ); + const collectionRef = React.useRef< + SerializedCollection | undefined + >(); + collectionRef.current = collection; + + const fetchMore = React.useCallback( + async () => + /* + * Ignore calls to fetchMore before collection is fetched for the first + * time + */ + currentCallback.current === callback + ? typeof fetchRef.current === 'object' + ? callback().then(f.undefined) + : callback().then((result) => + result !== undefined && + result.records.length > 0 && + // If the fetch function changed while fetching, discard the results + currentCallback.current === callback + ? setCollection((collection) => ({ + records: [ + ...defined( + collection, + 'Try to fetch more before collection is fetch.' + ).records, + ...result.records, + ], + totalCount: defined(collection).totalCount, + })) + : undefined + ) + : undefined, + [callback, collection] + ); + + return [collection, setCollection, fetchMore] as const; +} diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify_trees.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify_trees.json index 52fcc38131d..4a255c74ace 100644 --- a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify_trees.json +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify_trees.json @@ -538,7 +538,6 @@ "version": 8, "createdbyagent": null, "modifiedbyagent": "/api/specify/agent/1514/", - "institutions": "/api/specify/institution/?storagetreedef=1", "treeentries": "/api/specify/storage/?definition=1", "treedefitems": [ { @@ -736,7 +735,6 @@ "version": 3, "createdbyagent": null, "modifiedbyagent": "/api/specify/agent/1514/", - "disciplines": "/api/specify/discipline/?geographytreedef=1", "treeentries": "/api/specify/geography/?definition=1", "treedefitems": [ { diff --git a/specifyweb/permissions/permissions.py b/specifyweb/permissions/permissions.py index e329ae7d608..5f056915536 100644 --- a/specifyweb/permissions/permissions.py +++ b/specifyweb/permissions/permissions.py @@ -5,7 +5,6 @@ from django.db import connection from django.db.models import Model -from django.core.exceptions import ObjectDoesNotExist from specifyweb.specify.models import Agent from specifyweb.specify.datamodel import Table diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index b7eddaf1ffb..ce4f4c64b5c 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -6,21 +6,18 @@ import logging import re from typing import Any, Dict, List, Optional, Tuple, Iterable, Union, \ - Callable + Callable, TypedDict, cast from urllib.parse import urlencode from typing_extensions import TypedDict -from specifyweb.specify.tree_utils import TREE_MODELS, TREE_RANK_MODELS, TREE_ITEM_MODELS -from specifyweb.specify.tree_extras import is_treedefitem, is_treedef - logger = logging.getLogger(__name__) from django import forms from django.db import transaction from django.apps import apps from django.http import (HttpResponse, HttpResponseBadRequest, - Http404, HttpResponseNotAllowed, JsonResponse, QueryDict) + Http404, HttpResponseNotAllowed, QueryDict) from django.core.exceptions import ObjectDoesNotExist, FieldError, FieldDoesNotExist from django.db.models.fields import DateTimeField, FloatField, DecimalField @@ -32,6 +29,7 @@ from .uiformatters import AutonumberOverflowException from .filter_by_col import filter_by_collection from .auditlog import auditlog +from .datamodel import datamodel from .calculated_fields import calculate_extra_fields ReadPermChecker = Callable[[Any], None] @@ -493,13 +491,7 @@ def create_obj(collection, agent, model, data: Dict[str, Any], parent_obj=None): if obj.id is not None: # was the object actually saved? check_table_permissions(collection, agent, obj, "create") auditlog.insert(obj, agent, parent_obj) - if model in TREE_MODELS: - # handle_new_tree_creation(collection, agent, obj, data) - handle_to_many(collection, agent, obj, data, is_new_tree=True) - elif model in TREE_RANK_MODELS: - handle_to_many(collection, agent, obj, data, is_tree=True) - else: - handle_to_many(collection, agent, obj, data) + handle_to_many(collection, agent, obj, data) return obj FieldChangeInfo = TypedDict('FieldChangeInfo', {'field_name': str, 'old_value': Any, 'new_value': Any}) @@ -609,28 +601,15 @@ def handle_fk_fields(collection, agent, obj, data: Dict[str, Any]) -> Tuple[List elif isinstance(val, str): # The related object is given by a URI reference. assert not dependent, "didn't get inline data for dependent field %s in %s: %r" % (field_name, obj, val) - fk_model, fk_id = parse_uri(val) - assert fk_model == field.related_model.__name__.lower() - assert fk_id is not None + fk_model, fk_id = strict_uri_to_model(val, field.related_model.__name__) setattr(obj, field_name, get_object_or_404(fk_model, id=fk_id)) new_related_id = fk_id elif hasattr(val, 'items'): # i.e. it's a dict of some sort # The related object is represented by a nested dict of data. - assert dependent, "got inline data for non dependent field %s in %s: %r" % (field_name, obj, val) rel_model = field.related_model - if 'id' in val: - # The related object is an existing resource with an id. - # This should never happen. - rel_obj = update_obj(collection, agent, - rel_model, val['id'], - val['version'], val, - parent_obj=obj) - else: - # The related object is to be created. - rel_obj = create_obj(collection, agent, - rel_model, val, - parent_obj=obj) + + rel_obj = update_or_create_resource(collection, agent, rel_model, val, obj if dependent else None) setattr(obj, field_name, rel_obj) if dependent and old_related and old_related.id != rel_obj.id: @@ -644,14 +623,7 @@ def handle_fk_fields(collection, agent, obj, data: Dict[str, Any]) -> Tuple[List return dependents_to_delete, dirty -def handle_to_many( - collection, - agent, - obj, - data: Dict[str, Any], - is_tree: bool = False, - is_new_tree: bool = False, -) -> None: +def handle_to_many(collection, agent, obj, data: Dict[str, Any]) -> None: """For every key in the dict 'data' which is a *-to-many field in the Django model instance 'obj', if nested data is provided, use it to update the set of related objects. @@ -661,54 +633,111 @@ def handle_to_many( Nested data items with ids will be updated. Those without ids will be created as new resources. """ - if is_treedef(obj) or is_treedefitem(obj): - is_tree = True for field_name, val in list(data.items()): field = obj._meta.get_field(field_name) if not field.is_relation or (field.many_to_one or field.one_to_one): continue # Skip *-to-one fields. + dependent = is_dependent_field(obj, field_name) - if isinstance(val, list): - assert isinstance(obj, models.Recordset) or obj.specify_model.get_field(field_name).dependent, \ - "got inline data for non dependent field %s in %s: %r" % (field_name, obj, val) - else: - # The field contains something other than nested data. - # Probably the URI of the collection of objects. - assert not obj.specify_model.get_field(field_name).dependent, \ - "didn't get inline data for dependent field %s in %s: %r" % (field_name, obj, val) + if isinstance(val, list): + assert dependent or (isinstance(obj, models.Recordset) and field_name == 'recordsetitems'), \ + "got inline data for non dependent field %s in %s: %r" % (field_name, obj, val) + elif hasattr(val, "items"): + assert not dependent, "got inline dictionary data for dependent field %s in %s: %r" % (field_name, obj, val) + else: + # The field contains something other than nested data. + # Probably the URI of the collection continue - rel_model = field.related_model - ids = [] # Ids not in this list will be deleted at the end. - parent_treedefitem_id = None - for rel_data in val: - rel_data[field.field.name] = obj - - if (is_new_tree or is_tree) and parent_treedefitem_id is not None: - rel_data['parent'] = parent_treedefitem_id - - if 'id' in rel_data: - # Update an existing related object. - rel_obj = update_obj(collection, agent, - rel_model, rel_data['id'], - rel_data['version'], rel_data, - parent_obj=obj) - else: - # Create a new related object. - rel_obj = create_obj(collection, agent, rel_model, rel_data, parent_obj=obj) - - if is_new_tree or is_tree: - parent_treedefitem_id = uri_for_model(rel_obj.__class__, rel_obj.id) - - ids.append(rel_obj.id) # Record the id as one to keep. - - # Delete related objects not in the ids list. - # TODO: Check versions for optimistic locking. - to_delete = getattr(obj, field_name).exclude(id__in=ids) - for rel_obj in to_delete: - check_table_permissions(collection, agent, rel_obj, "delete") - auditlog.remove(rel_obj, agent, obj) - to_delete.delete() + if dependent or (isinstance(obj, models.Recordset) and field_name == 'recordsetitems'): + _handle_dependent_to_many(collection, agent, obj, field, val) + else: + _handle_independent_to_many(collection, agent, obj, field, val) + +def _handle_dependent_to_many(collection, agent, obj, field, value): + if not isinstance(value, list): + assert isinstance(value, list), "didn't get inline data for dependent field %s in %s: %r" % (field.name, obj, value) + + rel_model = field.related_model + ids = [] # Ids not in this list will be deleted (if dependent) or removed from obj (if independent) at the end. + + for rel_data in value: + rel_data[field.field.name] = obj + + rel_obj = update_or_create_resource(collection, agent, rel_model, rel_data, parent_obj=obj) + + ids.append(rel_obj.id) # Record the id as one to keep. + + # Delete related objects not in the ids list. + # TODO: Check versions for optimistic locking. + to_remove = getattr(obj, field.name).exclude(id__in=ids).select_for_update() + for rel_obj in to_remove: + check_table_permissions(collection, agent, rel_obj, "delete") + auditlog.remove(rel_obj, agent, obj) + + to_remove.delete() + +class IndependentInline(TypedDict): + update: List[Union[str, Dict[str, Any]]] + remove: List[str] + +def _handle_independent_to_many(collection, agent, obj, field, value: IndependentInline): + logger.warning("Updating independent collections via the API is experimental and the structure may be changed in the future") + + rel_model = field.related_model + + to_update = value.get('update', []) + to_remove = value.get('remove', []) + + ids_to_fetch: List[int] = [] + cached_objs: Dict[int, Dict[str, Any]] = dict() + fk_model = None + + to_fetch: Tuple[str, ...] = tuple(string_or_data for string_or_data in tuple((*to_update, *to_remove)) if isinstance(string_or_data, str)) + + # Fetch the related records which are provided as strings + for resource_uri in to_fetch: + fk_model, fk_id = strict_uri_to_model(resource_uri, rel_model.__name__) + ids_to_fetch.append(fk_id) + + if fk_model is not None: + cached_objs = {item.id: obj_to_data(item) for item in get_model(fk_model).objects.filter(id__in=ids_to_fetch).select_for_update()} + + for raw_rel_data in to_update: + if isinstance(raw_rel_data, str): + fk_model, fk_id = strict_uri_to_model(raw_rel_data, rel_model.__name__) + rel_data = cached_objs.get(fk_id) + if rel_data is None: + raise Http404(f"{rel_model.specify_model.name} with id {fk_id} does not exist") + if rel_data[field.field.name] == uri_for_model(obj.__class__, obj.id): + continue + else: + rel_data = raw_rel_data + + rel_data[field.field.name] = obj + update_or_create_resource(collection, agent, rel_model, rel_data, None) + + if len(to_remove) > 0: + assert obj.pk is not None, f"Unable to remove {obj.__class__.__name__}.{field.field.name} resources from new {obj.__class__.__name__}" + related_field = datamodel.reverse_relationship(obj.specify_model.get_field_strict(field.name)) + assert related_field is not None, f"no reverse relationship for {obj.__class__.__name__}.{field.field.name}" + for resource_uri in to_remove: + fk_model, fk_id = strict_uri_to_model(resource_uri, rel_model.__name__) + rel_data = cached_objs.get(fk_id) + if rel_data is None: + raise Http404(f"{rel_model.specify_model.name} with id {fk_id} does not exist") + assert rel_data[field.field.name] == uri_for_model(obj.__class__, obj.pk), f"Related {related_field.relatedModelName} does not belong to {obj.__class__.__name__}.{field.field.name}: {resource_uri}" + rel_data[field.field.name] = None + update_obj(collection, agent, rel_model, rel_data["id"], rel_data["version"], rel_data) + +def update_or_create_resource(collection, agent, model, data, parent_obj): + if 'id' in data: + return update_obj(collection, agent, + model, data['id'], + data['version'], data, + parent_obj=parent_obj) + else: + return create_obj(collection, agent, model, data, parent_obj=parent_obj) @transaction.atomic def delete_resource(collection, agent, name, id, version) -> None: @@ -815,6 +844,12 @@ def parse_uri(uri: str) -> Tuple[str, str]: groups = match.groups() return groups[0], groups[2] +def strict_uri_to_model(uri: str, model: str) -> Tuple[str, int]: + uri_model, uri_id = parse_uri(uri) + assert model.lower() == uri_model.lower(), f"{model} does not match model in uri: {uri_model}" + assert uri_id is not None + return uri_model, int(uri_id) + def obj_to_data(obj) -> Dict[str, Any]: "Wrapper for backwards compat w/ other modules that use this function." # TODO: Such functions should be audited for whether they should apply @@ -909,7 +944,7 @@ def apply_filters(logged_in_collection, params, model, control_params=GetCollect # filter out control parameters continue - if param.endswith('__in'): + if param.endswith('__in') or param.endswith('__range'): # this is a bit kludgy val = val.split(',') diff --git a/specifyweb/specify/apps.py b/specifyweb/specify/apps.py new file mode 100644 index 00000000000..e98dc10a28e --- /dev/null +++ b/specifyweb/specify/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig +from django.db.models import Transform, Field + + +class NotTransform(Transform): + lookup_name = 'not' + function = 'NOT' + + +class SpecifyConfig(AppConfig): + name='specifyweb.specify' + + def ready(self): + Field.register_lookup(NotTransform) diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index 3a8a704e614..23ac6c17255 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -481,7 +481,9 @@ def test_update_object_with_more_inlines(self): even_dets = [d for d in data['determinations'] if d['number1'] % 2 == 0] for d in even_dets: data['determinations'].remove(d) - data['collectionobjectattribute'] = {'text1': 'look! an attribute'} + text1_data = 'look! an attribute' + + data['collectionobjectattribute'] = {'text1': text1_data} api.update_obj(self.collection, self.agent, 'collectionobject', data['id'], data['version'], data) @@ -491,9 +493,172 @@ def test_update_object_with_more_inlines(self): for d in obj.determinations.all(): self.assertFalse(d.number1 % 2 == 0) - self.assertEqual(obj.collectionobjectattribute.text1, 'look! an attribute') + self.assertEqual(obj.collectionobjectattribute.text1, text1_data) + + def test_independent_to_many_set_inline(self): + accession_data = { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id), + 'collectionobjects': { + "update": [ + api.obj_to_data(self.collectionobjects[0]), + api.uri_for_model('collectionobject', self.collectionobjects[1].id) + ] + } + } + + accession = api.create_obj(self.collection, self.agent, 'Accession', accession_data) + self.collectionobjects[0].refresh_from_db() + self.collectionobjects[1].refresh_from_db() + self.assertEqual(accession, self.collectionobjects[0].accession) + self.assertEqual(accession, self.collectionobjects[1].accession) + + def test_independent_to_one_set_inline(self): + collection_object_data = { + 'collection': api.uri_for_model('collection', self.collection.id), + 'accession': { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id), + } + } + + created_co = api.create_obj(self.collection, self.agent, 'Collectionobject', collection_object_data) + self.assertIsNotNone(created_co.accession) + + def test_indepenent_to_many_removing_from_inline(self): + accession = models.Accession.objects.create( + accessionnumber="a", + version="0", + division=self.division + ) + + accession.collectionobjects.set(self.collectionobjects) + + self.assertEqual(accession, self.collectionobjects[0].accession) + + collection_objects_to_remove = [self.collectionobjects[0], self.collectionobjects[3]] + + cos_to_keep = [collection_object for collection_object in self.collectionobjects if not collection_object in collection_objects_to_remove] + + accession_data = { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id), + 'collectionobjects': { + "remove": [ + api.uri_for_model('collectionobject', collection_object.id) + for collection_object in collection_objects_to_remove + ] + } + } + accession = api.update_obj(self.collection, self.agent, 'Accession', accession.id, accession.version, accession_data) + + self.assertEqual(list(accession.collectionobjects.all()), cos_to_keep) + + # ensure the other CollectionObjects have not been deleted + self.assertEqual(len(models.Collectionobject.objects.all()), len(self.collectionobjects)) + + def test_updating_independent_to_many_resource(self): + co_to_modify = api.obj_to_data(self.collectionobjects[2]) + co_to_modify.update({ + 'integer1': 10, + 'determinations': [ + { + 'iscurrent': True, + 'collectionmemberid': self.collection.id, + 'collectionobject': api.uri_for_model('Collectionobject', self.collectionobjects[2].id) + } + ] + }) + + accession_data = { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id), + 'collectionobjects': { + "update": [ + co_to_modify + ] + } + } + + self.assertEqual(self.collectionobjects[2].integer1, None) + self.assertEqual(list(self.collectionobjects[2].determinations.all()), []) + accession = api.create_obj(self.collection, self.agent, 'Accession', accession_data) + self.collectionobjects[2].refresh_from_db() + self.assertEqual(self.collectionobjects[2].integer1, 10) + self.assertEqual(len(self.collectionobjects[2].determinations.all()), 1) + + def test_updating_independent_to_one_resource(self): + accession_data = { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id) + } + accession = api.create_obj(self.collection, self.agent, 'Accession', accession_data) + + accession_text = 'someText' + accession_data.update({ + 'id': accession.id, + 'accessionnumber': "a1", + 'text1': accession_text, + 'version': accession.version + }) + collection_object_data = { + 'collection': api.uri_for_model('collection', self.collection.id), + 'accession': accession_data + } + + self.assertEqual(accession.text1, None) + self.assertEqual(accession.accessionnumber, 'a') + created_co = api.create_obj(self.collection, self.agent, 'Collectionobject', collection_object_data) + accession.refresh_from_db() + self.assertEqual(accession.text1, accession_text) + self.assertEqual(accession.accessionnumber, 'a1') + + def test_independent_to_many_creating_from_remoteside(self): + new_catalognumber = f'num-{len(self.collectionobjects)}' + accession_data = { + 'accessionnumber': "a", + 'division': api.uri_for_model('division', self.division.id), + 'collectionobjects': { + "update": [ + { + 'catalognumber': new_catalognumber, + 'collection': api.uri_for_model('Collection', self.collection.id) + } + ] + } + } + + accession = api.create_obj(self.collection, self.agent, 'Accession', accession_data) + self.assertTrue(models.Collectionobject.objects.filter(catalognumber=new_catalognumber).exists()) + + def test_reassigning_independent_to_many(self): + acc1 = models.Accession.objects.create( + accessionnumber="a", + division = self.division + ) + self.collectionobjects[0].accession = acc1 + self.collectionobjects[0].save() + self.collectionobjects[1].accession = acc1 + self.collectionobjects[1].save() + + accession_data = { + 'accessionnumber': "b", + 'division': api.uri_for_model('division', self.division.id), + 'collectionobjects': { + "update": [ + api.obj_to_data(self.collectionobjects[0]), + api.uri_for_model('collectionobject', self.collectionobjects[1].id) + ] + } + } + acc2 = api.create_obj(self.collection, self.agent, 'Accession', accession_data) + self.collectionobjects[0].refresh_from_db() + self.collectionobjects[1].refresh_from_db() + self.assertEqual(self.collectionobjects[0].accession, acc2) + self.assertEqual(self.collectionobjects[1].accession, acc2) + # version control on inlined resources should be tested diff --git a/specifyweb/specify/tests/test_trees.py b/specifyweb/specify/tests/test_trees.py index b2774ea4334..d8769c649d7 100644 --- a/specifyweb/specify/tests/test_trees.py +++ b/specifyweb/specify/tests/test_trees.py @@ -10,6 +10,7 @@ from contextlib import contextmanager from django.db import connection + class TestTreeSetup(ApiTests): def setUp(self) -> None: super().setUp() @@ -19,8 +20,8 @@ def setUp(self) -> None: self.geographytreedef.treedefitems.create(name='County', rankid=400) self.geographytreedef.treedefitems.create(name='City', rankid=500) - - self.taxontreedef = models.Taxontreedef.objects.create(name="Test Taxonomy") + self.taxontreedef = models.Taxontreedef.objects.create( + name="Test Taxonomy") self.taxontreedef.treedefitems.create(name='Taxonomy Root', rankid=0) self.taxontreedef.treedefitems.create(name='Kingdom', rankid=10) self.taxontreedef.treedefitems.create(name='Phylum', rankid=30) @@ -38,14 +39,16 @@ def setUp(self) -> None: self.collection.collectionobjecttype = self.collectionobjecttype self.collection.save() + class TestTree: - def setUp(self)->None: + def setUp(self) -> None: super().setUp() - + self.earth = self.make_geotree("Earth", "Planet") - self.na = self.make_geotree("North America", "Continent", parent=self.earth) + self.na = self.make_geotree( + "North America", "Continent", parent=self.earth) self.usa = self.make_geotree("USA", "Country", parent=self.na) @@ -57,22 +60,31 @@ def setUp(self)->None: self.doug = self.make_geotree("Douglas", "County", parent=self.kansas) self.greene = self.make_geotree("Greene", "County", parent=self.mo) self.greeneoh = self.make_geotree("Greene", "County", parent=self.ohio) - self.sangomon = self.make_geotree("Sangamon", "County", parent=self.ill) + self.sangomon = self.make_geotree( + "Sangamon", "County", parent=self.ill) - self.springmo = self.make_geotree("Springfield", "City", parent=self.greene) - self.springill = self.make_geotree("Springfield", "City", parent=self.sangomon) + self.springmo = self.make_geotree( + "Springfield", "City", parent=self.greene) + self.springill = self.make_geotree( + "Springfield", "City", parent=self.sangomon) def make_geotree(self, name, rank_name, **extra_kwargs): return get_table("Geography").objects.create( name=name, - definitionitem=get_table('Geographytreedefitem').objects.get(name=rank_name), + definitionitem=get_table( + 'Geographytreedefitem').objects.get(name=rank_name), definition=self.geographytreedef, **extra_kwargs ) - -class GeographyTree(TestTree, TestTreeSetup): pass -class SqlTreeSetup(SQLAlchemySetup, GeographyTree): pass + +class GeographyTree(TestTree, TestTreeSetup): + pass + + +class SqlTreeSetup(SQLAlchemySetup, GeographyTree): + pass + class TreeViewsTest(SqlTreeSetup): @@ -128,14 +140,15 @@ def setUp(self): ) def _run_nn_and_cte(*args, **kwargs): - cte_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=True) - node_number_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=False) + cte_results = get_tree_stats( + *args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=True) + node_number_results = get_tree_stats( + *args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=False) self.assertCountEqual(cte_results, node_number_results) return cte_results self.validate_tree_stats = lambda *args, **kwargs: ( lambda true_results: self.assertCountEqual(_run_nn_and_cte(*args, **kwargs), true_results)) - def test_counts_correctness(self): correct_results = { @@ -153,26 +166,31 @@ def test_counts_correctness(self): self.sangomon.id: [ (self.springill.id, 1, 1) ] - } + } _results = [ - self.validate_tree_stats(self.geographytreedef.id, 'geography', parent_id, self.collection)(correct) + self.validate_tree_stats( + self.geographytreedef.id, 'geography', parent_id, self.collection)(correct) for parent_id, correct in correct_results.items() ] - + def test_test_synonyms_concat(self): self.maxDiff = None na_syn_0 = self.make_geotree("NA Syn 0", "Continent", - acceptedgeography=self.na, + acceptedgeography=self.na, # fullname is not set by default for not-accepted fullname="NA Syn 0", parent=self.earth ) - na_syn_1 = self.make_geotree("NA Syn 1", "Continent", acceptedgeography=self.na, fullname="NA Syn 1", parent=self.earth) + na_syn_1 = self.make_geotree( + "NA Syn 1", "Continent", acceptedgeography=self.na, fullname="NA Syn 1", parent=self.earth) - usa_syn_0 = self.make_geotree("USA Syn 0", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 0") - usa_syn_1 = self.make_geotree("USA Syn 1", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 1") - usa_syn_2 = self.make_geotree("USA Syn 2", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 2") + usa_syn_0 = self.make_geotree( + "USA Syn 0", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 0") + usa_syn_1 = self.make_geotree( + "USA Syn 1", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 1") + usa_syn_2 = self.make_geotree( + "USA Syn 2", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 2") # need to refresh _some_ nodes (but not all) # just the immediate parents and siblings inserted before us @@ -199,38 +217,47 @@ def _run_for_row(): self.geographytreedef.id, "Geography", self.earth.id, "geographyid", False, session ) expected = [ - (self.na.id, self.na.name, self.na.fullname, self.na.nodenumber, self.na.highestchildnodenumber, self.na.rankid, None, None, 'NULL', self.na.children.count(), 'NA Syn 0, NA Syn 1'), - (na_syn_0.id, na_syn_0.name, na_syn_0.fullname, na_syn_0.nodenumber, na_syn_0.highestchildnodenumber, na_syn_0.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_0.children.count(), None), - (na_syn_1.id, na_syn_1.name, na_syn_1.fullname, na_syn_1.nodenumber, na_syn_1.highestchildnodenumber, na_syn_1.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_1.children.count(), None), - ] + (self.na.id, self.na.name, self.na.fullname, self.na.nodenumber, self.na.highestchildnodenumber, + self.na.rankid, None, None, 'NULL', self.na.children.count(), 'NA Syn 0, NA Syn 1'), + (na_syn_0.id, na_syn_0.name, na_syn_0.fullname, na_syn_0.nodenumber, na_syn_0.highestchildnodenumber, + na_syn_0.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_0.children.count(), None), + (na_syn_1.id, na_syn_1.name, na_syn_1.fullname, na_syn_1.nodenumber, na_syn_1.highestchildnodenumber, + na_syn_1.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_1.children.count(), None), + ] self.assertCountEqual( results, expected ) - + with _run_for_row() as session: results = get_tree_rows( self.geographytreedef.id, "Geography", self.na.id, "name", False, session ) expected = [ - (self.usa.id, self.usa.name, self.usa.fullname, self.usa.nodenumber, self.usa.highestchildnodenumber, self.usa.rankid, None, None, 'NULL', self.usa.children.count(), 'USA Syn 0, USA Syn 1, USA Syn 2'), - (usa_syn_0.id, usa_syn_0.name, usa_syn_0.fullname, usa_syn_0.nodenumber, usa_syn_0.highestchildnodenumber, usa_syn_0.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), - (usa_syn_1.id, usa_syn_1.name, usa_syn_1.fullname, usa_syn_1.nodenumber, usa_syn_1.highestchildnodenumber, usa_syn_1.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), - (usa_syn_2.id, usa_syn_2. name, usa_syn_2.fullname, usa_syn_2.nodenumber, usa_syn_2.highestchildnodenumber, usa_syn_2.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None) + (self.usa.id, self.usa.name, self.usa.fullname, self.usa.nodenumber, self.usa.highestchildnodenumber, + self.usa.rankid, None, None, 'NULL', self.usa.children.count(), 'USA Syn 0, USA Syn 1, USA Syn 2'), + (usa_syn_0.id, usa_syn_0.name, usa_syn_0.fullname, usa_syn_0.nodenumber, + usa_syn_0.highestchildnodenumber, usa_syn_0.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), + (usa_syn_1.id, usa_syn_1.name, usa_syn_1.fullname, usa_syn_1.nodenumber, + usa_syn_1.highestchildnodenumber, usa_syn_1.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), + (usa_syn_2.id, usa_syn_2. name, usa_syn_2.fullname, usa_syn_2.nodenumber, + usa_syn_2.highestchildnodenumber, usa_syn_2.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None) - ] + ] self.assertCountEqual( results, expected ) + class AddDeleteRankResourcesTest(ApiTests): def test_add_ranks_without_defaults(self): c = Client() c.force_login(self.specifyuser) - treedef_geo = models.Geographytreedef.objects.create(name='GeographyTest') + treedef_geo = models.Geographytreedef.objects.create( + name='GeographyTest') # Test adding non-default rank on empty heirarchy data = { @@ -239,8 +266,10 @@ def test_add_ranks_without_defaults(self): 'treedef': treedef_geo, 'rankid': 100 } - universe_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(100, models.Geographytreedefitem.objects.get(name='Universe').rankid) + universe_rank = api.create_obj( + self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(100, models.Geographytreedefitem.objects.get( + name='Universe').rankid) # Test adding non-default rank to the end of the heirarchy data = { @@ -249,8 +278,10 @@ def test_add_ranks_without_defaults(self): 'treedef': treedef_geo, 'rankid': 200 } - galaxy_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(200, models.Geographytreedefitem.objects.get(name='Galaxy').rankid) + galaxy_rank = api.create_obj( + self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual( + 200, models.Geographytreedefitem.objects.get(name='Galaxy').rankid) # Test adding non-default rank to the front of the heirarchy data = { @@ -259,8 +290,10 @@ def test_add_ranks_without_defaults(self): 'treedef': treedef_geo, 'rankid': 50 } - multiverse_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(50, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + multiverse_rank = api.create_obj( + self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(50, models.Geographytreedefitem.objects.get( + name='Multiverse').rankid) # Test adding non-default rank in the middle of the heirarchy data = { @@ -269,11 +302,14 @@ def test_add_ranks_without_defaults(self): 'treedef': treedef_geo, 'rankid': 150 } - dimersion_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Dimension').rankid) + dimersion_rank = api.create_obj( + self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(150, models.Geographytreedefitem.objects.get( + name='Dimension').rankid) # Test foreign keys - self.assertEqual(4, models.Geographytreedefitem.objects.filter(treedef=treedef_geo).count()) + self.assertEqual(4, models.Geographytreedefitem.objects.filter( + treedef=treedef_geo).count()) # Create test nodes cfc = models.Geography.objects.create(name='Central Finite Curve', rankid=50, definition=treedef_geo, @@ -285,7 +321,7 @@ def test_add_ranks_without_defaults(self): milky_way = models.Geography.objects.create(name='Milky Way', parent=d3, rankid=200, definition=treedef_geo, definitionitem=models.Geographytreedefitem.objects.get( name='Galaxy')) - + # Test full name reconstruction set_fullnames(treedef_geo, null_only=False, node_number_range=None) if cfc.fullname is not None: @@ -296,13 +332,12 @@ def test_add_ranks_without_defaults(self): self.assertEqual('3D', d3.fullname) if milky_way.fullname is not None: self.assertEqual('Milky Way', milky_way.fullname) - + # Test parents of child nodes self.assertEqual(cfc.id, c137.parent.id) self.assertEqual(c137.id, d3.parent.id) self.assertEqual(d3.id, milky_way.parent.id) - def test_add_ranks_with_defaults(self): c = Client() c.force_login(self.specifyuser) @@ -313,20 +348,24 @@ def test_add_ranks_with_defaults(self): data = { 'name': 'Taxonomy Root', 'parent': None, - 'treedef': treedef_taxon + 'treedef': api.uri_for_model('Taxontreedef', treedef_taxon.id) } - taxon_root_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) + taxon_root_rank = api.create_obj( + self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual(0, models.Taxontreedefitem.objects.get( + name='Taxonomy Root').rankid) # Test adding non-default rank in front of rank 0 data = { 'name': 'Invalid', 'parent': None, - 'treedef': treedef_taxon + 'treedef': api.uri_for_model('Taxontreedef', treedef_taxon.id) } with self.assertRaises(TreeBusinessRuleException): - api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(0, models.Taxontreedefitem.objects.filter(name='Invalid').count()) + api.create_obj(self.collection, self.agent, + 'taxontreedefitem', data) + self.assertEqual(0, models.Taxontreedefitem.objects.filter( + name='Invalid').count()) # Test adding default rank to the end of the heirarchy data = { @@ -334,8 +373,10 @@ def test_add_ranks_with_defaults(self): 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), 'treedef': treedef_taxon } - division_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(30, models.Taxontreedefitem.objects.get(name='Division').rankid) + division_rank = api.create_obj( + self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual(30, models.Taxontreedefitem.objects.get( + name='Division').rankid) # Test adding default rank to the middle of the heirarchy data = { @@ -343,8 +384,10 @@ def test_add_ranks_with_defaults(self): 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), 'treedef': treedef_taxon } - kingdom_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(10, models.Taxontreedefitem.objects.get(name='Kingdom').rankid) + kingdom_rank = api.create_obj( + self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual( + 10, models.Taxontreedefitem.objects.get(name='Kingdom').rankid) self.assertEqual(models.Taxontreedefitem.objects.get(name='Division').parent.id, models.Taxontreedefitem.objects.get(name='Kingdom').id) self.assertEqual(models.Taxontreedefitem.objects.get(name='Kingdom').parent.id, @@ -363,7 +406,7 @@ def test_add_ranks_with_defaults(self): definitionitem=models.Taxontreedefitem.objects.get(name='Division')) blastoise = models.Taxon.objects.create(name='Blastoise', parent=water, rankid=200, definition=treedef_taxon, definitionitem=models.Taxontreedefitem.objects.get(name='Division')) - + # Test full name reconstruction set_fullnames(treedef_taxon, null_only=False, node_number_range=None) if pokemon.fullname is not None: @@ -379,7 +422,8 @@ def test_delete_ranks(self): c = Client() c.force_login(self.specifyuser) - treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriodTest') + treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create( + name='GeographyTimePeriodTest') era_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Era', rankid=100, @@ -405,15 +449,97 @@ def test_delete_ranks(self): ) # Test deleting a rank in the middle of the heirarchy - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', epoch_rank.id, epoch_rank.version) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Epoch').first()) - self.assertEqual(period_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parent.id) + api.delete_resource(self.collection, self.agent, + 'Geologictimeperiodtreedefitem', epoch_rank.id, epoch_rank.version) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter( + name='Epoch').first()) + self.assertEqual(period_rank.id, models.Geologictimeperiodtreedefitem.objects.get( + name='Age').parent.id) # Test deleting a rank at the end of the heirarchy - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', age_rank.id, age_rank.version) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Age').first()) + api.delete_resource(self.collection, self.agent, + 'Geologictimeperiodtreedefitem', age_rank.id, age_rank.version) + self.assertEqual( + None, models.Geologictimeperiodtreedefitem.objects.filter(name='Age').first()) # Test deleting a rank at the head of the heirarchy with self.assertRaises(TreeBusinessRuleException): - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', era_rank.id, era_rank.version) - \ No newline at end of file + api.delete_resource(self.collection, self.agent, + 'Geologictimeperiodtreedefitem', era_rank.id, era_rank.version) + + def test_parents_inferred_treedef_inline(self): + default_taxon_tree = { + "name": "Default Taxon Tree", + "remarks": "A default taxon tree", + "fullnamedirection": 1, + "discipline": api.uri_for_model("Discipline", self.discipline.id), + "treedefitems": [ + { + "name": "Life", + "title": "Life", + "isenforced": True, + "isinfullname": False, + "rankid": 0, + }, + { + "name": "Kingdom", + "title": "Kingdom", + "isenforced": True, + "isinfullname": False, + "rankid": 10, + }, + { + "name": "Phylum", + "title": "Phylum", + "isenforced": True, + "isinfullname": False, + "rankid": 30, + }, + { + "name": "Class", + "title": "Class", + "isenforced": True, + "isinfullname": False, + "rankid": 60, + }, + { + "name": "Order", + "title": "Order", + "isenforced": True, + "isinfullname": False, + "rankid": 100, + }, + { + "name": "Family", + "title": "Family", + "isenforced": True, + "isinfullname": False, + "rankid": 140, + }, + { + "name": "Genus", + "title": "Genus", + "isenforced": True, + "isinfullname": True, + "rankid": 180, + }, + { + "name": "Species", + "title": "Species", + "isenforced": True, + "isinfullname": True, + "rankid": 220, + }, + ], + } + + created_treedef = api.create_obj(self.collection, self.agent, "Taxontreedef", default_taxon_tree) + root = models.Taxontreedefitem.objects.get(treedef=created_treedef, rankid=0) + self.assertEqual(root.name, "Life") + self.assertIsNone(root.parent) + family = models.Taxontreedefitem.objects.get(treedef=created_treedef, rankid=140) + self.assertEqual(family.name, "Family") + self.assertEqual(family.parent, models.Taxontreedefitem.objects.get(treedef=created_treedef, rankid=100)) + species = models.Taxontreedefitem.objects.get(treedef=created_treedef, rankid=220) + self.assertEqual(species.name, "Species") + self.assertEqual(species.parent, models.Taxontreedefitem.objects.get(treedef=created_treedef, rankid=180)) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index a0609402db1..4555529bbfa 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -1,13 +1,9 @@ -from functools import wraps -from hmac import new -from operator import ge +from sys import maxsize from enum import Enum -from django.db.models import Count from specifyweb.businessrules.exceptions import TreeBusinessRuleException -from . import tree_extras from . import models as spmodels -from sys import maxsize +from . import tree_extras import logging logger = logging.getLogger(__name__) @@ -131,17 +127,11 @@ def is_tree_rank_empty(tree_rank_model, tree_rank) -> bool: return False return tree_item_model.objects.filter(definitionitem=tree_rank).count() == 0 -def post_tree_rank_save(tree_def_item_model, new_rank): +def post_tree_rank_save(tree_def_item_model, new_rank: "tree_extras.TreeRank"): tree_def = new_rank.treedef parent_rank = new_rank.parent - new_rank_id = new_rank.rankid - - # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = ( - tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank) - .exclude(id=new_rank.id) - .update(parent=new_rank) - ) + if parent_rank: + parent_rank.children.exclude(id=new_rank.id).update(parent=new_rank) # Regenerate full names tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) @@ -159,17 +149,22 @@ def post_tree_rank_deletion(rank): # Regenerate full names tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) -def pre_tree_rank_init(new_rank): +def pre_tree_rank_init(new_rank: "tree_extras.TreeRank"): set_rank_id(new_rank) + _set_parent(new_rank) + +def _set_parent(new_rank: "tree_extras.TreeRank"): + if new_rank.parent: return + new_rank.parent = new_rank._meta.model.objects.filter(treedef=new_rank.treedef, rankid__lt=new_rank.rankid).order_by("-rankid").first() -def set_rank_id(new_rank): +def set_rank_id(new_rank: "tree_extras.TreeRank"): """ Sets the new rank to the specified tree when adding a new rank. Expects at least the name, parent, and tree_def of the rank to be set. All the other parameters are optional. """ # Get parameter values from data - tree = new_rank.specify_model.name.replace("TreeDefItem", "").lower() + tree_name = new_rank.specify_model.name.replace("TreeDefItem", "").lower() new_rank_name = new_rank.name parent_rank_name = new_rank.parent.name if new_rank.parent else 'root' tree_def = getattr(new_rank, 'treedef', None) @@ -181,11 +176,11 @@ def set_rank_id(new_rank): raise TreeBusinessRuleException("Parent rank name is not given") if tree_def is None: raise TreeBusinessRuleException("Tree definition is not given") - if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): + if tree_name is None or tree_name.lower() not in TREE_RANKS_MAPPING.keys(): raise TreeBusinessRuleException("Invalid tree type") # Get tree def item model - tree_def_item_model_name = (tree + 'treedefitem').lower().title() + tree_def_item_model_name = (tree_name + 'treedefitem').lower().title() tree_def_item_model = getattr(spmodels, tree_def_item_model_name) # Handle case where the parent rank is not given, and it is not the first rank added. @@ -236,7 +231,7 @@ def set_rank_id(new_rank): is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 # Set the default ranks and increments depending on the tree type - default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower()) + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree_name.lower()) # In the future, add this as a function parameter to allow for more flexibility. # use_default_rank_ids can be set to false if you do not want to use the default rank ids. diff --git a/specifyweb/specify/tree_utils.py b/specifyweb/specify/tree_utils.py index e02d545c6f6..bca4e4a2b27 100644 --- a/specifyweb/specify/tree_utils.py +++ b/specifyweb/specify/tree_utils.py @@ -7,31 +7,6 @@ SPECIFY_TREES = {"taxon", "storage", "geography", "geologictimeperiod", "lithostrat", 'tectonicunit'} -TREE_MODELS = { - spmodels.Taxontreedef, - spmodels.Geographytreedef, - spmodels.Storagetreedef, - spmodels.Geologictimeperiodtreedef, - spmodels.Lithostrattreedef, - spmodels.Tectonicunittreedef, -} -TREE_RANK_MODELS = { - spmodels.Taxontreedefitem, - spmodels.Geographytreedefitem, - spmodels.Storagetreedefitem, - spmodels.Geologictimeperiodtreedefitem, - spmodels.Lithostrattreedefitem, - spmodels.Tectonicunittreedefitem, -} -TREE_ITEM_MODELS = { - spmodels.Taxon, - spmodels.Geography, - spmodels.Storage, - spmodels.Geologictimeperiod, - spmodels.Lithostrat, - spmodels.Tectonicunit, -} - def get_search_filters(collection: spmodels.Collection, tree: str): tree_name = tree.lower() if tree_name == 'storage':