diff --git a/config/backstop/app_resources.xml b/config/backstop/app_resources.xml index 97329256324..0e2585d0c42 100644 --- a/config/backstop/app_resources.xml +++ b/config/backstop/app_resources.xml @@ -3,4 +3,5 @@ + diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json new file mode 100644 index 00000000000..b3f115171b5 --- /dev/null +++ b/config/backstop/data_view_queries.json @@ -0,0 +1,1203 @@ +{ + "version": 1, + "queries": { + "Accession": { + "fields": [ + { + "tableList": "7", + "stringId": "7.accession.accessionNumber", + "fieldName": "accessionNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.dateAccessioned", + "fieldName": "dateAccessioned", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.dateReceived", + "fieldName": "dateReceived", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.status", + "fieldName": "status", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.type", + "fieldName": "type", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7,1-collectionObjects", + "stringId": "7,1-collectionObjects.collectionobject.collectionObjects", + "fieldName": "collectionObjects", + "isRelFld": true, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 6, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Agent": { + "fields": [ + { + "tableList": "5", + "stringId": "5.agent.lastName", + "fieldName": "lastName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.firstName", + "fieldName": "firstName", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.middleInitial", + "fieldName": "middleInitial", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.email", + "fieldName": "email", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.title", + "fieldName": "title", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 5, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Borrow": { + "fields": [ + { + "tableList": "18", + "stringId": "18.borrow.invoiceNumber", + "fieldName": "invoiceNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.borrowDate", + "fieldName": "borrowDate", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.currentDueDate", + "fieldName": "currentDueDate", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.dateClosed", + "fieldName": "dateClosed", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.receivedDate", + "fieldName": "receivedDate", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.isClosed", + "fieldName": "isClosed", + "isRelFld": false, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 6, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectingEvent": { + "fields": [ + { + "tableList": "10", + "stringId": "10.collectingevent.stationFieldNumber", + "fieldName": "stationFieldNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10", + "stringId": "10.collectingevent.method", + "fieldName": "method", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10", + "stringId": "10.collectingevent.startDate", + "fieldName": "startDate", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.localityName", + "fieldName": "localityName", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.latitude1", + "fieldName": "latitude1", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.longitude1", + "fieldName": "longitude1", + "isRelFld": false, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.datum", + "fieldName": "datum", + "isRelFld": false, + "sortType": 0, + "position": 6, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2,3", + "stringId": "10,2,3.geography.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 7, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10", + "stringId": "10.collectingevent.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 8, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectingTrip": { + "fields": [ + { + "tableList": "87", + "stringId": "87.collectingtrip.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 1, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Collection": { + "fields": [ + { + "tableList": "23", + "stringId": "23.collection.collectionName", + "fieldName": "collectionName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23", + "stringId": "23.collection.code", + "fieldName": "code", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23", + "stringId": "23.collection.collectionType", + "fieldName": "collectionType", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23,26", + "stringId": "23,26.discipline.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23,26,96", + "stringId": "23,26,96.division.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23", + "stringId": "23.collection.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 5, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectionObject": { + "fields": [ + { + "tableList": "1", + "stringId": "1.collectionobject.catalogNumber", + "fieldName": "catalogNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,5-cataloger", + "stringId": "1,5-cataloger.agent.cataloger", + "fieldName": "cataloger", + "isRelFld": true, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1", + "stringId": "1.collectionobject.catalogedDate", + "fieldName": "catalogedDate", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations,4-preferredTaxon", + "stringId": "1,9-determinations,4-preferredTaxon.taxon.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations", + "stringId": "1,9-determinations.determination.isCurrent", + "fieldName": "isCurrent", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": false, + "operStart": 6, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,10", + "stringId": "1,10.collectingevent.collectingEvent", + "fieldName": "collectingEvent", + "isRelFld": true, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,63-preparations", + "stringId": "1,63-preparations.preparation.preparations", + "fieldName": "preparations", + "isRelFld": true, + "sortType": 0, + "position": 6, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,10,30-collectors", + "stringId": "1,10,30-collectors.collector.collectors", + "fieldName": "collectors", + "isRelFld": true, + "sortType": 0, + "position": 7, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations", + "stringId": "1,9-determinations.determination.determinations", + "fieldName": "determinations", + "isRelFld": true, + "sortType": 0, + "position": 8, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1", + "stringId": "1.collectionobject.timestampCreated", + "fieldName": "timestampCreated", + "isRelFld": false, + "sortType": 2, + "position": 9, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Appraisal": { + "fields": [ + { + "tableList": "67", + "stringId": "67.appraisal.appraisalNumber", + "fieldName": "appraisalNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67", + "stringId": "67.appraisal.appraisalDate", + "fieldName": "appraisalDate", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67", + "stringId": "67.appraisal.appraisalValue", + "fieldName": "appraisalValue", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67,1-collectionObjects", + "stringId": "67,1-collectionObjects.collectionobject.collectionObjects", + "fieldName": "collectionObjects", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67", + "stringId": "67.appraisal.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 4, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Deaccession": { + "fields": [ + { + "tableList": "163", + "stringId": "163.deaccession.deaccessionNumber", + "fieldName": "deaccessionNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163", + "stringId": "163.deaccession.status", + "fieldName": "status", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,34-disposals", + "stringId": "163,34-disposals.disposal.disposals", + "fieldName": "disposals", + "isRelFld": true, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,40-exchangeOuts", + "stringId": "163,40-exchangeOuts.exchangeout.exchangeOuts", + "fieldName": "exchangeOuts", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,131-gifts", + "stringId": "163,131-gifts.gift.gifts", + "fieldName": "gifts", + "isRelFld": true, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163", + "stringId": "163.deaccession.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 5, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Discipline": { + "fields": [ + { + "tableList": "26", + "stringId": "26.discipline.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "26", + "stringId": "26.discipline.type", + "fieldName": "type", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "26,96", + "stringId": "26,96.division.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "26", + "stringId": "26.discipline.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 3, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Gift": { + "fields": [ + { + "tableList": "131", + "stringId": "131.gift.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 1, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Loan": { + "fields": [ + { + "tableList": "52", + "stringId": "52.loan.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 1, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Locality": { + "fields": [ + { + "tableList": "2", + "stringId": "2.locality.localityName", + "fieldName": "localityName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.latitude1", + "fieldName": "latitude1", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.longitude1", + "fieldName": "longitude1", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.datum", + "fieldName": "datum", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2,3", + "stringId": "2,3.geography.geography", + "fieldName": "geography", + "isRelFld": true, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 5, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "PrepType": { + "fields": [ + { + "tableList": "65", + "stringId": "65.preptype.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "65", + "stringId": "65.preptype.isLoanable", + "fieldName": "isLoanable", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "65", + "stringId": "65.preptype.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 2, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Taxon": { + "fields": [ + { + "tableList": "4", + "stringId": "4.taxon.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4", + "stringId": "4.taxon.author", + "fieldName": "author", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4", + "stringId": "4.taxon.isAccepted", + "fieldName": "isAccepted", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4,4-children", + "stringId": "4,4-children.taxon.children", + "fieldName": "children", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4", + "stringId": "4.taxon.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 4, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Storage": { + "fields": [ + { + "tableList": "58", + "stringId": "58.storage.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "58,58-children", + "stringId": "58,58-children.storage.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "58", + "stringId": "58.storage.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 2, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Geography": { + "fields": [ + { + "tableList": "3", + "stringId": "3.geography.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "3,3-children", + "stringId": "3,3-children.geography.children", + "fieldName": "children", + "isRelFld": true, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "3", + "stringId": "3.geography.timestampModified", + "fieldName": "timestampModified", + "isRelFld": false, + "sortType": 2, + "position": 2, + "isDisplay": false, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + } + } +} diff --git a/config/backstop/dataobj_formatters.xml b/config/backstop/dataobj_formatters.xml index c12c121e3dc..9d30f45d9b9 100644 --- a/config/backstop/dataobj_formatters.xml +++ b/config/backstop/dataobj_formatters.xml @@ -522,7 +522,7 @@ - + @@ -550,5 +550,11 @@ + + + + + + \ No newline at end of file diff --git a/specifyweb/backend/context/app_resource.py b/specifyweb/backend/context/app_resource.py index bd57471dfe3..b1b46bfb886 100644 --- a/specifyweb/backend/context/app_resource.py +++ b/specifyweb/backend/context/app_resource.py @@ -5,6 +5,7 @@ database or the filesystem with database resources taking precedence over the filesystem. """ import errno +import json import logging import os from xml.etree import ElementTree @@ -62,6 +63,11 @@ def get_app_resource(collection, user, resource_name, additional_default=False): logger.info('looking for app resource %r for user %s in %s', resource_name, user and user.name, collection and collection.collectionname) + # Data View queries are keyed by table, so a more-specific resource can + # override individual tables while inheriting the remaining definitions. + if resource_name == 'DataViewQueries': + return get_data_view_queries_resource(collection, user) + # Handling for DataObjFormatters to support fallback to defaults if resource_name == 'DataObjFormatters': custom_formatter = None @@ -119,6 +125,39 @@ def get_app_resource(collection, user, resource_name, additional_default=False): # resource was not found return None + +def get_data_view_queries_resource(collection, user): + """Return DataViewQueries merged across the app-resource hierarchy.""" + resources = [] + for level in DIR_LEVELS: + resource = get_app_resource_from_db(collection, user, level, 'DataViewQueries') + if resource is None: + resource = load_resource_at_level(collection, user, level, 'DataViewQueries') + if resource is not None: + resources.append(resource) + + if not resources: + return None + + queries = {} + for resource, _mimetype, _id in reversed(resources): + try: + data = json.loads(resource) + except (TypeError, json.JSONDecodeError): + continue + if ( + not isinstance(data, dict) + or data.get('version') != 1 + or not isinstance(data.get('queries'), dict) + ): + continue + queries.update(data['queries']) + + # Return the most-specific resource's metadata while exposing the merged + # JSON payload to the client. + _resource, mimetype, resource_id = resources[0] + return json.dumps({'version': 1, 'queries': queries}), mimetype, resource_id + def get_usertype(user): return user and user.usertype and user.usertype.replace(' ', '').lower() diff --git a/specifyweb/backend/context/tests/test_app_resource.py b/specifyweb/backend/context/tests/test_app_resource.py index 9cdc4a68776..e326edd346d 100644 --- a/specifyweb/backend/context/tests/test_app_resource.py +++ b/specifyweb/backend/context/tests/test_app_resource.py @@ -3,12 +3,36 @@ from specifyweb.specify.tests.test_api import ApiTests from unittest.mock import Mock, patch +from specifyweb.backend.context.app_resource import get_data_view_queries_resource + FOUND_RESOURCE = ('"Value"', "text/xml", 4) class TestAppResource(ApiTests): + @patch("specifyweb.backend.context.app_resource.load_resource_at_level") + @patch("specifyweb.backend.context.app_resource.get_app_resource_from_db") + def test_data_view_queries_inherit_by_table( + self, get_from_db: Mock, load_from_filesystem: Mock + ): + discipline = ('{"version": 1, "queries": {"Agent": {"fields": [1]}, "Loan": {"fields": [2]}}}', 'application/json', 1) + personal = ('{"version": 1, "queries": {"Agent": {"fields": [3]}}}', 'application/json', 2) + get_from_db.side_effect = lambda _collection, _user, level, _name: { + 'Discipline': discipline, + 'Personal': personal, + }.get(level) + load_from_filesystem.return_value = None + + result = get_data_view_queries_resource(self.collection, self.specifyuser) + + self.assertIsNotNone(result) + self.assertEqual( + result[0], + '{"version": 1, "queries": {"Agent": {"fields": [3]}, "Loan": {"fields": [2]}}}', + ) + self.assertEqual(result[1:], ('application/json', 2)) + def test_no_name(self): c = Client() c.force_login(self.specifyuser) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index 32576883d96..afa89dd59d4 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -39,6 +39,7 @@ import { WebLinkEditor } from '../WebLinks/Editor'; import { webLinksSpec } from '../WebLinks/spec'; import { useCodeMirrorExtensions } from './EditorComponents'; import type { appResourceSubTypes } from './types'; +import { DataViewQueryEditor } from '../DataViews/QueryEditor'; export type AppResourceEditorType = 'generic' | 'json' | 'visual' | 'xml'; @@ -159,6 +160,10 @@ export const visualAppResourceEditors = f.store< visual: UserPreferencesEditor, json: AppResourceTextEditor, }, + dataViewQueries: { + visual: DataViewQueryEditor, + json: AppResourceTextEditor, + }, collectionPreferences: { visual: CollectionPreferencesEditor, json: AppResourceTextEditor, diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx index 471c51d4d04..54626b82ab3 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx @@ -60,6 +60,7 @@ describe('AppResourcesFilters', () => { 'collectionPreferences', 'dataEntryTables', 'dataObjectFormatters', + 'dataViewQueries', 'defaultUserPreferences', 'expressSearchConfig', 'interactionsTables', diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts index 293b78f890a..7f14c15133e 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts @@ -2,25 +2,26 @@ import { allAppResources } from '../filtersHelpers'; test('allAppResources', () => { expect(allAppResources).toMatchInlineSnapshot(` - [ - "collectionPreferences", - "dataEntryTables", - "dataObjectFormatters", - "defaultUserPreferences", - "expressSearchConfig", - "interactionsTables", - "label", - "leafletLayers", - "otherAppResources", - "otherJsonResource", - "otherPropertiesResource", - "otherXmlResource", - "report", - "rssExportFeed", - "typeSearches", - "uiFormatters", - "userPreferences", - "webLinks", - ] - `); +[ + "collectionPreferences", + "dataEntryTables", + "dataObjectFormatters", + "dataViewQueries", + "defaultUserPreferences", + "expressSearchConfig", + "interactionsTables", + "label", + "leafletLayers", + "otherAppResources", + "otherJsonResource", + "otherPropertiesResource", + "otherXmlResource", + "report", + "rssExportFeed", + "typeSearches", + "uiFormatters", + "userPreferences", + "webLinks", +] +`); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts index 4be98addc17..e2c4c6f2913 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts @@ -2,28 +2,29 @@ import { defaultAppResourceFilters } from '../filtersHelpers'; test('defaultAppResourceFilters', () => { expect(defaultAppResourceFilters).toMatchInlineSnapshot(` - { - "appResources": [ - "collectionPreferences", - "dataEntryTables", - "dataObjectFormatters", - "defaultUserPreferences", - "expressSearchConfig", - "interactionsTables", - "label", - "leafletLayers", - "otherAppResources", - "otherJsonResource", - "otherPropertiesResource", - "otherXmlResource", - "report", - "rssExportFeed", - "typeSearches", - "uiFormatters", - "userPreferences", - "webLinks", - ], - "viewSets": true, - } - `); +{ + "appResources": [ + "collectionPreferences", + "dataEntryTables", + "dataObjectFormatters", + "dataViewQueries", + "defaultUserPreferences", + "expressSearchConfig", + "interactionsTables", + "label", + "leafletLayers", + "otherAppResources", + "otherJsonResource", + "otherPropertiesResource", + "otherXmlResource", + "report", + "rssExportFeed", + "typeSearches", + "uiFormatters", + "userPreferences", + "webLinks", + ], + "viewSets": true, +} +`); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 8a0b8f089da..b45cc958313 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -9,6 +9,7 @@ import { icons } from '../Atoms/Icons'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpAppResourceDir, Tables } from '../DataModel/types'; import type { AppResourceMode } from './helpers'; +import { dataViewsText } from '../../localization/dataViews'; export type AppResourceScope = | 'collection' @@ -102,6 +103,15 @@ export const appResourceSubTypes = ensure>()({ icon: icons.cog, label: preferencesText.defaultUserPreferences(), }, + dataViewQueries: { + mimeType: 'application/json', + name: 'DataViewQueries', + documentationUrl: undefined, + icon: icons.eye, + label: dataViewsText.dataViewQueries(), + useTemplate: false, + scope: ['collection', 'discipline', 'user'], + }, // TODO: There should be useTemplate: false below? (like it is for userPreferences) collectionPreferences: { mimeType: 'application/json', diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 2f3f53217c4..a7ab4b11d73 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -4,45 +4,102 @@ import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; import { Http } from '../../utils/ajax/definitions'; import { throttledPromise } from '../../utils/ajax/throttledPromise'; -import type { GetSet, IR, RA } from '../../utils/types'; +import type { IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; import { serializeResource } from '../DataModel/serializers'; import { SpecifyTable } from '../DataModel/specifyTable'; -import { getTableById, strictGetTable } from '../DataModel/tables'; -import { Tables } from '../DataModel/types'; +import type { Tables } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { userPreferences } from '../Preferences/userPreferences'; import { OverlayContext } from '../Router/Router'; -import { tablesFilter } from '../SchemaConfig/Tables'; import { queryCountPromiseGenerator, querySpecToResource, } from '../Statistics/hooks'; import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; import { QueryTables } from '../Toolbar/QueryTablesWrapper'; - -const defaultDataViewTablesConfig: RA = [ - 'Accession', - 'Agent', - 'CollectionObject', - 'CollectingEvent', - 'Gift', - 'Loan', - 'Locality', -]; +import { defaultDataViewTablesConfig, useDataViewTables } from './config'; +import { DataViewQueryEditorContent } from './QueryEditor'; +import { + getDataViewQueryDefinition, + saveUserDataViewQueries, + serializeDataViewQueries, + useDataViewQueries, +} from './queries'; export function DataViewTables(): JSX.Element { const handleClose = React.useContext(OverlayContext); const [tables, setTables] = useDataViewTables(); const [isEditing, handleEditing] = useBooleanState(); + const [queries, reloadQueries] = useDataViewQueries(); + const [queryData, setQueryData] = React.useState(); + const [isSavingQuery, setIsSavingQuery] = React.useState(false); + const [queryTable, setQueryTable] = React.useState< + keyof Tables | undefined + >(); const counts = useTableRecordCounts(tables); + const handleOpenQueryEditor = (tableName: keyof Tables): void => { + if (queries === undefined) return; + setQueryData( + serializeDataViewQueries({ + version: 1, + queries: { + [tableName]: getDataViewQueryDefinition(queries, tableName), + }, + }) + ); + setQueryTable(tableName); + }; + const handleCloseQueryEditor = (): void => { + setQueryTable(undefined); + setQueryData(undefined); + }; + if (queryTable !== undefined && queryData !== undefined) + return ( + + + {commonText.cancel()} + + { + if (isSavingQuery) return; + setIsSavingQuery(true); + saveUserDataViewQueries(queryData, queryTable) + .then(reloadQueries) + .then(handleCloseQueryEditor) + .catch(raise) + .finally(() => setIsSavingQuery(false)); + }} + > + {commonText.save()} + + + } + header={dataViewsText.configureQuery()} + onClose={isSavingQuery ? undefined : handleCloseQueryEditor} + > + + + ); return isEditing ? ( } + headerButtons={ +
+ +
+ } icon={icons.eye} onClose={handleClose} > @@ -70,6 +131,7 @@ export function DataViewTables(): JSX.Element { getHref={(name) => `/specify/dataviews/${name.toLowerCase()}`} tables={tables} onClick={undefined} + onEdit={handleOpenQueryEditor} /> ); @@ -79,13 +141,15 @@ export function DataViewTables(): JSX.Element { * Fetches the number of records in each table in the background, using the * same count-only query builder logic that is used in the Statistics page. */ -function useTableRecordCounts( +export function useTableRecordCounts( tables: RA ): IR { const [counts, setCounts] = React.useState>({}); React.useEffect(() => { let destructorCalled = false; + const tableNames = new Set(tables.map(({ name }) => name)); + setCounts({}); tables.forEach((table) => { const query = serializeResource( querySpecToResource(table.name, { @@ -102,7 +166,7 @@ function useTableRecordCounts( JSON.stringify(query) ) .then((count) => { - if (destructorCalled) return; + if (destructorCalled || !tableNames.has(table.name)) return; setCounts((previousCounts) => ({ ...previousCounts, [table.name]: count, @@ -117,29 +181,3 @@ function useTableRecordCounts( return counts; } - -function useDataViewTables(): GetSet> { - const [tables, setTables] = userPreferences.use( - 'dataViews', - 'general', - 'shownTables' - ); - const allowedTables = React.useMemo(() => { - const visibleTables = - tables.length === 0 - ? defaultDataViewTablesConfig.map(strictGetTable) - : tables.map(getTableById); - - return visibleTables.filter((table) => - tablesFilter(true, false, true, table) - ); - }, [tables]); - - const handleChange = React.useCallback( - (models: RA) => - setTables(models.map((model) => model.tableId)), - [setTables] - ); - - return [allowedTables, handleChange]; -} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx new file mode 100644 index 00000000000..693aa08ba47 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -0,0 +1,154 @@ +import React from 'react'; + +import { dataViewsText } from '../../localization/dataViews'; +import type { RA } from '../../utils/types'; +import type { AppResourceTabProps } from '../AppResources/TabDefinitions'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpQueryField, Tables } from '../DataModel/types'; +import { QueryBuilder } from '../QueryBuilder/Wrapped'; +import { defaultDataViewTablesConfig } from './config'; +import { + getStoredDataViewQueryDefinition, + makeDataViewQuery, + parseDataViewQueries, + serializeDataViewQueries, + serializeStableDataViewQueries, + type DataViewQueriesFile, +} from './queries'; +import { CollapsibleTableList } from '../SchemaConfig/Tables'; +import { useCachedState } from '../../hooks/useCachedState'; + +export function DataViewQueryEditor({ + data, + onChange: handleChange, +}: AppResourceTabProps): JSX.Element { + return ( + handleChange(nextData)} + /> + ); +} + +export function DataViewQueryEditorContent({ + data, + onChange: handleChange, + tableName: lockedTableName, +}: { + readonly data: string | null | undefined; + readonly onChange: (data: string) => void; + readonly tableName?: keyof Tables; +}): JSX.Element { + const initialFile = React.useMemo(() => parseDataViewQueries(data), [data]); + const [file, setFile] = React.useState(initialFile); + const fileRef = React.useRef(file); + const [rememberedTable, setRememberedTable] = useCachedState( + 'appResources', + 'dataViewQueriesTable' + ); + const [tableName, setTableName] = React.useState( + lockedTableName ?? rememberedTable ?? defaultDataViewTablesConfig[0] + ); + + React.useEffect(() => { + if (lockedTableName !== undefined) setTableName(lockedTableName); + }, [lockedTableName]); + + React.useEffect(() => { + if ( + serializeStableDataViewQueries(fileRef.current) === + serializeStableDataViewQueries(initialFile) + ) + return; + fileRef.current = initialFile; + setFile(initialFile); + }, [initialFile]); + + const query = React.useMemo( + () => + makeDataViewQuery( + tableName, + getStoredDataViewQueryDefinition(fileRef.current, tableName) ?? { + fields: [], + } + ), + // The Query Builder owns its live field state. Replacing its query + // resource for every field change resets that state and the field list's + // scroll position. Rebuild only when changing tables. + [tableName] + ); + + const handleQueryChange = React.useCallback( + (changes: { + readonly fields: RA>; + readonly isDistinct: boolean | null; + readonly searchSynonymy: boolean | null; + readonly isSeries: boolean | null; + }): void => { + if ( + changes.fields.length === 0 && + getStoredDataViewQueryDefinition(fileRef.current, tableName) === + undefined + ) + return; + const currentFile = fileRef.current; + const nextFile: DataViewQueriesFile = { + ...currentFile, + queries: { + ...currentFile.queries, + [tableName]: { + fields: changes.fields, + selectDistinct: changes.isDistinct ?? false, + searchSynonymy: changes.searchSynonymy ?? false, + smushed: changes.isSeries ?? false, + }, + }, + }; + if ( + serializeStableDataViewQueries(nextFile) === + serializeStableDataViewQueries(fileRef.current) + ) + return; + fileRef.current = nextFile; + setFile(nextFile); + handleChange(serializeDataViewQueries(nextFile)); + }, + [handleChange, tableName] + ); + + return ( +
+ {lockedTableName === undefined ? ( + + getStoredDataViewQueryDefinition(file, table.name) === + undefined ? undefined : ( + + ) + } + cacheKey="appResources" + currentTableName={tableName} + getAction={(table): (() => void) => + (): void => { + setTableName(table.name); + setRememberedTable(table.name); + }} + /> + ) : undefined} +
+ +
+

{dataViewsText.configureQuery()}

+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx new file mode 100644 index 00000000000..c8c9bd556a4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx @@ -0,0 +1,70 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { Http } from '../../../utils/ajax/definitions'; +import { throttledPromise } from '../../../utils/ajax/throttledPromise'; +import { + queryCountPromiseGenerator, + querySpecToResource, +} from '../../Statistics/hooks'; +import { useTableRecordCounts } from '../DataViewTables'; + +jest.mock('../../../utils/ajax/throttledPromise', () => ({ + throttledPromise: jest.fn( + (_key: string, generator: () => Promise) => generator() + ), +})); + +jest.mock('../../DataModel/serializers', () => ({ + serializeResource: (resource: unknown) => resource, +})); + +jest.mock('../../Statistics/hooks', () => ({ + queryCountPromiseGenerator: jest.fn(), + querySpecToResource: jest.fn((tableName: string) => ({ tableName })), +})); + +test('fetches and returns a record count for each Data Views table', async () => { + (queryCountPromiseGenerator as jest.Mock).mockImplementation( + ({ tableName }: { readonly tableName: string }) => + async () => ({ + status: Http.OK, + data: { count: tableName === 'Agent' ? 3 : 7 }, + }) + ); + const tables = [{ name: 'Agent' }, { name: 'Loan' }] as never; + + const { result } = renderHook(() => useTableRecordCounts(tables)); + + await waitFor(() => expect(result.current).toEqual({ Agent: 3, Loan: 7 })); + + expect(querySpecToResource).toHaveBeenCalledTimes(2); + expect(throttledPromise).toHaveBeenCalledTimes(2); +}); + +test('clears stale record counts when Data Views tables change', async () => { + (queryCountPromiseGenerator as jest.Mock).mockImplementation( + ({ tableName }: { readonly tableName: string }) => + async () => ({ + status: Http.OK, + data: { count: tableName === 'Agent' ? 3 : 7 }, + }) + ); + const agentTable = { name: 'Agent' } as never; + const loanTable = { name: 'Loan' } as never; + + const { result, rerender } = renderHook( + ({ tables }) => useTableRecordCounts(tables), + { initialProps: { tables: [agentTable] } } + ); + + await waitFor(() => expect(result.current).toEqual({ Agent: 3 })); + rerender({ tables: [loanTable] }); + + expect(result.current).toEqual({}); + + await waitFor(() => expect(result.current).toEqual({ Loan: 7 })); + rerender({ tables: [agentTable] }); + + expect(result.current).toEqual({}); + await waitFor(() => expect(result.current).toEqual({ Agent: 3 })); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts new file mode 100644 index 00000000000..fb48423ce9a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts @@ -0,0 +1,161 @@ +import { addMissingFields } from '../../DataModel/addMissingFields'; +import { serializeResource } from '../../DataModel/serializers'; +import { strictGetTable } from '../../DataModel/tables'; +import { requireContext } from '../../../tests/helpers'; +import { + defaultDataViewQuery, + getDataViewQueryDefinition, + getStoredDataViewQueryDefinition, + makeDataViewQuery, + parseDataViewQueries, + serializeDataViewQueries, +} from '../queries'; +import { getNumericResultId } from '../index'; + +requireContext(); + +test('invalid Data View query resources fall back to an empty version 1 file', () => { + expect(parseDataViewQueries('{invalid')).toEqual({ version: 1, queries: {} }); + expect(parseDataViewQueries('{"version":2,"queries":{}}')).toEqual({ + version: 1, + queries: {}, + }); + expect(parseDataViewQueries('{"version":1,"queries":null}')).toEqual({ + version: 1, + queries: {}, + }); + expect(parseDataViewQueries('{"version":1,"queries":[]}')).toEqual({ + version: 1, + queries: {}, + }); +}); + +test('result IDs accept finite numbers and numeric strings only', () => { + expect(getNumericResultId(42)).toBe(42); + expect(getNumericResultId(' 42 ')).toBe(42); + expect(getNumericResultId('')).toBeUndefined(); + expect(getNumericResultId('invalid')).toBeUndefined(); + expect(getNumericResultId(Number.NaN)).toBeUndefined(); + expect(getNumericResultId(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(getNumericResultId(null)).toBeUndefined(); +}); + +test('Data View query definitions round trip as JSON', () => { + const data = { + version: 1 as const, + queries: { + Agent: { + fields: [addMissingFields('SpQueryField', { fieldName: 'Name' })], + selectDistinct: true, + }, + }, + }; + expect(parseDataViewQueries(serializeDataViewQueries(data))).toEqual(data); + expect(parseDataViewQueries(data)).toEqual(data); +}); + +test('missing table definitions use generated defaults', () => { + const file = parseDataViewQueries(undefined); + const definition = getDataViewQueryDefinition(file, 'Agent'); + + expect(getStoredDataViewQueryDefinition(file, 'Agent')).toBeUndefined(); + expect(definition.fields.length).toBeGreaterThan(0); + expect(definition.selectDistinct).toBe(false); + expect(defaultDataViewQuery('Agent')).toEqual(definition); +}); + +test('empty Data View query definitions create an empty query', () => { + const query = makeDataViewQuery('Agent', { fields: [] }); + + expect(serializeResource(query).fields).toEqual([]); +}); + +test('malformed table definitions use generated defaults', () => { + const file = parseDataViewQueries( + JSON.stringify({ version: 1, queries: { Agent: { selectDistinct: true } } }) + ); + const definition = getDataViewQueryDefinition(file, 'Agent'); + + expect(definition).toEqual(defaultDataViewQuery('Agent')); + expect(() => makeDataViewQuery('Agent', definition)).not.toThrow(); +}); + +test('table definitions with null fields use generated defaults', () => { + const file = parseDataViewQueries( + JSON.stringify({ version: 1, queries: { Agent: { fields: [null] } } }) + ); + + expect(getStoredDataViewQueryDefinition(file, 'Agent')).toBeUndefined(); + expect(getDataViewQueryDefinition(file, 'Agent')).toEqual( + defaultDataViewQuery('Agent') + ); +}); + +test('stored table definitions override defaults in runtime queries', () => { + const definition = { + fields: [addMissingFields('SpQueryField', { fieldName: 'Name' })], + selectDistinct: true, + searchSynonymy: true, + smushed: true, + }; + const file = { + version: 1 as const, + queries: { Agent: definition }, + }; + const storedDefinition = getDataViewQueryDefinition(file, 'Agent'); + expect(getStoredDataViewQueryDefinition(file, 'Agent')).toBe(definition); + const query = makeDataViewQuery('Agent', storedDefinition); + + expect(storedDefinition).toBe(definition); + expect(serializeResource(query).fields).toHaveLength(1); + expect(query.get('selectDistinct')).toBe(true); + expect(query.get('searchSynonymy')).toBe(true); + expect(query.get('smushed')).toBe(true); +}); + +test('generated defaults include every unhidden literal field and a hidden audit timestamp sort', () => { + const table = strictGetTable('Agent'); + const expected = new Set([ + ...table.literalFields + .filter( + ({ isHidden, isVirtual, isRelationship, name }) => + !isHidden && + !isVirtual && + !isRelationship && + !['id', 'timestampcreated', 'timestampmodified', 'version'].includes( + name.toLowerCase() + ) && + name !== table.idField.name + ) + .map(({ name }) => name), + ]); + const fields = defaultDataViewQuery('Agent').fields; + console.log( + 'Generated Agent default fields:', + fields.map(({ fieldName }) => fieldName) + ); + + expect( + new Set( + fields + .filter(({ isDisplay }) => isDisplay !== false) + .map(({ fieldName }) => fieldName) + ) + ).toEqual(expected); + expect(fields.at(-1)).toMatchObject({ + fieldName: expect.stringMatching(/timestamp(created|modified)/i), + isDisplay: false, + sortType: 2, + }); +}); + +test('runtime queries are ephemeral and use the configured fields', () => { + const definition = defaultDataViewQuery('Agent'); + const query = makeDataViewQuery('Agent', definition); + expect(query.isNew()).toBe(true); + expect(query.get('contextName')).toBe('Agent'); + expect(query.get('specifyUser')).toBeDefined(); + expect(serializeResource(query).fields).toHaveLength( + definition.fields.length + ); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/config.ts b/specifyweb/frontend/js_src/lib/components/DataViews/config.ts new file mode 100644 index 00000000000..66e56d3e680 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/config.ts @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { RA } from '../../utils/types'; +import type { GetSet } from '../../utils/types'; +import { getTableById, strictGetTable } from '../DataModel/tables'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import type { Tables } from '../DataModel/types'; +import { userPreferences } from '../Preferences/userPreferences'; +import { tablesFilter } from '../SchemaConfig/Tables'; + +export const defaultDataViewTablesConfig: RA = [ + 'Accession', + 'Agent', + 'CollectionObject', + 'CollectingEvent', + 'Gift', + 'Loan', + 'Locality', +]; + +export function useDataViewTables(): GetSet> { + const [tables, setTables] = userPreferences.use( + 'dataViews', + 'general', + 'shownTables' + ); + const allowedTables = React.useMemo(() => { + const visibleTables = + tables.length === 0 + ? defaultDataViewTablesConfig.map(strictGetTable) + : tables.map(getTableById); + + return visibleTables.filter((table) => + tablesFilter(true, false, true, table) + ); + }, [tables]); + + const handleChange = React.useCallback( + (models: RA) => + setTables(models.map((model) => model.tableId)), + [setTables] + ); + + return [allowedTables, handleChange]; +} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index e02a579f7ae..fbfb47152fc 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,151 +1,353 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import { - type CollectionFetchFilters, - DEFAULT_FETCH_LIMIT, - fetchCollection, -} from '../DataModel/collection'; -import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; -import { SpecifyTable } from '../DataModel/specifyTable'; + +import { commonText } from '../../localization/common'; +import { dataViewsText } from '../../localization/dataViews'; +import { Button } from '../Atoms/Button'; +import { DataEntry } from '../Atoms/DataEntry'; +import { H2 } from '../Atoms'; +import type { Tables } from '../DataModel/types'; import { getTable } from '../DataModel/tables'; import { ProtectedTable } from '../Permissions/PermissionDenied'; import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; +import { parseQueryFields, unParseQueryFields } from '../QueryBuilder/helpers'; +import { queryIdField } from '../QueryBuilder/Results'; +import { + SplitView, + SplitViewOrientationButton, + SplitViewToggleButton, + useSplitViewOrientation, +} from '../QueryBuilder/SplitView'; import { NotFoundView } from '../Router/NotFoundView'; -import { f } from '../../utils/functools'; -import { commonText } from '../../localization/common'; -import { dataViewsText } from '../../localization/dataViews'; -import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; -import { Tables } from '../DataModel/types'; -import { useAsyncState } from '../../hooks/useAsyncState'; -import { RA } from '../../utils/types'; -import { Label } from '../Atoms/Form'; -import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; -import { attachmentsText } from '../../localization/attachments'; +import { Dialog } from '../Molecules/Dialog'; +import { raise } from '../Errors/Crash'; +import { + getDataViewQueryDefinition, + makeDataViewQuery, + saveUserDataViewQueries, + serializeDataViewQueries, + useDataViewQueries, +} from './queries'; +import type { DataViewQueriesFile } from './queries'; +import { DataViewQueryEditorContent } from './QueryEditor'; +import { TableIcon } from '../Molecules/TableIcon'; +import { userPreferences } from '../Preferences/userPreferences'; +import { listen } from '../../utils/events'; + +const SMALL_SCREEN_WIDTH = 768; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); - const table = getTable(tableName); return table === undefined ? ( ) : ( - + ); } -function DataViewFromTableWrapped({ - table, +export function getNumericResultId(value: unknown): number | undefined { + const numericId = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : undefined; + return typeof numericId === 'number' && Number.isFinite(numericId) + ? numericId + : undefined; +} + +function DataViewFromTable({ + tableName, }: { - readonly table: SpecifyTable; + readonly tableName: keyof Tables; }): JSX.Element | null { - const defaultOrder = - table.getLiteralField('timestampCreated') === undefined - ? '-id' - : '-timestampCreated'; - - const [order, setOrder] = React.useState>( - defaultOrder as OrderPickerOrder + const [queries, reloadQueries] = useDataViewQueries(); + return queries === undefined ? null : ( + ); +} +function LoadedDataViewFromTable({ + tableName, + queries, + reloadQueries, +}: { + readonly tableName: keyof Tables; + readonly queries: DataViewQueriesFile; + readonly reloadQueries: () => void; +}): JSX.Element | null { + const table = getTable(tableName); + const [selectedIds, setSelectedIds] = React.useState>( + [] + ); + const selectedIdsRef = React.useRef(selectedIds); + selectedIdsRef.current = selectedIds; + const resultOrderRef = React.useRef>([]); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const resultsScrollRef = React.useRef(null); + const restoreScrollTopRef = React.useRef(undefined); + const [splitViewByDefault] = userPreferences.use( + 'dataViews', + 'general', + 'splitViewByDefault' + ); + const [splitViewOrientation] = userPreferences.use( + 'dataViews', + 'general', + 'splitViewOrientation' + ); + const [rawIsSplit, setIsSplit] = React.useState(splitViewByDefault); + const [canSplit, setCanSplit] = React.useState( + window.innerWidth >= SMALL_SCREEN_WIDTH + ); React.useEffect(() => { - setOrder(defaultOrder as OrderPickerOrder); - }, [table.name, defaultOrder]); - - const handleFetchingCollection = React.useCallback( - (offset: number = 0) => - fetchCollection(table.name, { - offset, - limit: DEFAULT_FETCH_LIMIT, - domainFilter: true, - orderBy: order, - } as CollectionFetchFilters), - [order, table] + const handleResize = (): void => + setCanSplit(window.innerWidth >= SMALL_SCREEN_WIDTH); + handleResize(); + return listen(window, 'resize', handleResize); + }, []); + const isSplit = rawIsSplit && canSplit; + const { isHorizontal, toggleOrientation } = useSplitViewOrientation( + splitViewOrientation === 'horizontal' + ); + const [refreshToken, setRefreshToken] = React.useState(0); + const [queryRunCount, setQueryRunCount] = React.useState(1); + const [queryData, setQueryData] = React.useState(); + const [isSavingQuery, setIsSavingQuery] = React.useState(false); + const [runtimeFields, setRuntimeFields] = React.useState< + ReturnType | undefined + >(undefined); + const selectedRows = React.useMemo( + () => [new Set(selectedIds), (): void => undefined] as const, + [selectedIds] ); + React.useEffect(() => { + setSelectedIds([]); + setSelectedIndex(0); + }, [tableName]); - const [collection] = useAsyncState(handleFetchingCollection, true); + const handleResults = React.useCallback( + (rows: ReadonlyArray | undefined>): void => { + const orderedIds = rows.flatMap((row) => { + const id = getNumericResultId(row?.[queryIdField]); + return id === undefined ? [] : [id]; + }); + resultOrderRef.current = orderedIds; - return collection === undefined ? null : ( - - handleFetchingCollection(index).then(({ records }) => records) + if (selectedIdsRef.current.length === 0) { + const firstId = orderedIds[0]; + if (firstId !== undefined) { + setSelectedIds([firstId]); + setSelectedIndex(0); + } + return; } - headerButtons={ - <> - - {attachmentsText.orderBy()} -
- - additionalFields={[{ name: 'id', label: commonText.id() }]} - includeHiddenFields - includeVirtualFields={false} - order={order} - table={table} - onChange={setOrder} - /> -
-
- - } - /> + + const positions = new Map( + orderedIds.map((id, index) => [id, index] as const) + ); + const reordered = [...selectedIdsRef.current].sort( + (left, right) => + (positions.get(left) ?? Number.MAX_SAFE_INTEGER) - + (positions.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + setSelectedIds(reordered); + }, + [] ); -} + const handleRefresh = React.useCallback((): void => { + if (resultsScrollRef.current !== null) + restoreScrollTopRef.current = resultsScrollRef.current.scrollTop; + setRefreshToken((token) => token + 1); + }, []); + const handleCloseQueryEditor = (): void => setQueryData(undefined); + const handleOpenQueryEditor = (): void => + setQueryData( + serializeDataViewQueries({ + version: 1, + queries: { + [tableName]: getDataViewQueryDefinition(queries, tableName), + }, + }) + ); + if (table === undefined) return null; -function DataViewFromTable({ - table, - totalCount: initialTotalCount, - initialRecords, - onFetchRecords: handleFetchRecords, - headerButtons, -}: { - readonly table: SpecifyTable; - readonly totalCount: number; - readonly initialRecords: RA>; - readonly onFetchRecords: ( - index: number - ) => Promise>>; - readonly headerButtons: JSX.Element; -}): JSX.Element | null { - // FEATURE: Use useNavigator and keep current record/index in query - // parameter of page + const definition = React.useMemo( + () => getDataViewQueryDefinition(queries, tableName), + [queries, tableName] + ); + const query = React.useMemo( + () => + makeDataViewQuery(tableName, { + ...definition, + fields: runtimeFields ?? definition.fields, + }), + [definition, runtimeFields, tableName] + ); + const fields = React.useMemo( + () => parseQueryFields(runtimeFields ?? definition.fields), + [definition.fields, runtimeFields] + ); - const { - results: [collection], - totalCount: [totalCount], - onFetchMore: handleFetchMore, - } = usePaginatedCollection({ - fetchMore: handleFetchRecords, - initialRecords, - totalCount: initialTotalCount, - }); + if (queryData !== undefined) + return ( + + + {commonText.cancel()} + + { + if (isSavingQuery) return; + setIsSavingQuery(true); + saveUserDataViewQueries(queryData, tableName) + .then(() => { + setRuntimeFields(undefined); + reloadQueries(); + }) + .then(handleCloseQueryEditor) + .catch(raise) + .finally(() => setIsSavingQuery(false)); + }} + > + {commonText.save()} + + + } + header={dataViewsText.configureQuery()} + onClose={isSavingQuery ? undefined : handleCloseQueryEditor} + > + + + ); - return collection === undefined ? null : ( - record?.id)} - isDependent={false} - isInRecordSet={false} - newResource={undefined} - table={table} - title={dataViewsText.tableRecords({ tableLabel: table.label })} - totalCount={totalCount} - onAdd={undefined} - onClone={undefined} - onClose={() => undefined} - onDelete={undefined} - onSaved={f.void} - onSlide={(new_index) => { - handleFetchMore(new_index); + const results = ( + { + setRuntimeFields(unParseQueryFields(table.name, newFields)); + setQueryRunCount((count) => count + 1); }} + onSelected={(ids): void => { + const positions = new Map( + resultOrderRef.current.map((id, index) => [id, index] as const) + ); + const orderedIds = [...ids].sort( + (left, right) => + (positions.get(left) ?? Number.MAX_SAFE_INTEGER) - + (positions.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + setSelectedIds(orderedIds); + const focusedId = ids.at(-1); + setSelectedIndex( + focusedId === undefined + ? 0 + : Math.max(0, orderedIds.indexOf(focusedId)) + ); + }} + queryRunCount={queryRunCount} + queryResource={query} + recordSetId={undefined} + forceCollection={undefined} + containerClassName="!rounded-none" + tableClassName="rounded-none" + fields={fields} + selectedRows={selectedRows} + table={table} + onResults={handleResults} + refreshToken={refreshToken} + restoreScrollTopRef={restoreScrollTopRef} + scrollRef={resultsScrollRef} /> ); + const form = ( +
+ {selectedIds.length === 0 ? ( +

{commonText.select()}

+ ) : ( + { + setSelectedIds([]); + setSelectedIndex(0); + }} + onDelete={undefined} + onSaved={handleRefresh} + onSlide={(index): void => setSelectedIndex(index)} + /> + )} +
+ ); + + return ( +
+
+
+ +

+ {dataViewsText.tableRecords({ tableLabel: table.label })} +

+ +
+ setIsSplit((split) => !split)} + /> + + +
+
+ {isSplit ? ( + + ) : ( + results + )} +
+
+ ); } diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts new file mode 100644 index 00000000000..1b3b0b80cc8 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts @@ -0,0 +1,270 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { addMissingFields } from '../DataModel/addMissingFields'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { serializeResource } from '../DataModel/serializers'; +import { strictGetTable } from '../DataModel/tables'; +import type { SpQuery, SpQueryField, Tables } from '../DataModel/types'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { createQuery } from '../QueryBuilder'; +import { getAppResourceUrl } from '../../utils/ajax/helpers'; +import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; +import { ping } from '../../utils/ajax/ping'; +import { clearUrlCache } from '../RouterCommands/CacheBuster'; +import { keysToLowerCase } from '../../utils/utils'; +import type { RA, RR } from '../../utils/types'; + +export const dataViewQueriesResourceName = 'DataViewQueries'; + +export type DataViewQueryDefinition = { + readonly fields: RA>; + readonly selectDistinct?: boolean; + readonly searchSynonymy?: boolean; + readonly smushed?: boolean; +}; + +export type DataViewQueriesFile = { + readonly version: 1; + readonly queries: Partial>; +}; + +const emptyDataViewQueries: DataViewQueriesFile = { version: 1, queries: {} }; + +function isDataViewQueriesFile(value: unknown): value is DataViewQueriesFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false; + const { version, queries } = value as { + readonly version?: unknown; + readonly queries?: unknown; + }; + return ( + version === 1 && + typeof queries === 'object' && + queries !== null && + !Array.isArray(queries) + ); +} + +function isDataViewQueryDefinition( + value: unknown +): value is DataViewQueryDefinition { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Array.isArray((value as { readonly fields?: unknown }).fields) && + (value as { readonly fields: unknown[] }).fields.every( + (field) => + typeof field === 'object' && field !== null && !Array.isArray(field) + ) + ); +} + +export function parseDataViewQueries(data: unknown): DataViewQueriesFile { + if (data === null || data === undefined) return emptyDataViewQueries; + try { + const parsed: unknown = + typeof data === 'string' + ? data.trim() === '' + ? undefined + : JSON.parse(data) + : data; + return isDataViewQueriesFile(parsed) ? parsed : emptyDataViewQueries; + } catch { + return emptyDataViewQueries; + } +} + +export function serializeDataViewQueries(data: DataViewQueriesFile): string { + return JSON.stringify(data, undefined, 2); +} + +/** Ignore transient Backbone resource metadata when comparing editor state. */ +export function serializeStableDataViewQueries( + data: DataViewQueriesFile +): string { + return JSON.stringify(data, (key, value: unknown) => + new Set([ + 'id', + 'resource_uri', + 'timestampCreated', + 'timestampModified', + 'version', + ]).has(key) + ? undefined + : value + ); +} + +export function defaultDataViewQuery( + tableName: keyof Tables +): DataViewQueryDefinition { + const table = strictGetTable(tableName); + const hiddenAuditTimestampField = [ + 'timestampModified', + 'timestampCreated', + ].find((fieldName) => + table.literalFields.some( + ({ name, isVirtual, isRelationship }) => + !isVirtual && + !isRelationship && + name.toLowerCase() === fieldName.toLowerCase() + ) + ); + const fields = table.literalFields + .filter( + ({ isHidden, isVirtual, name, isRelationship }) => + !isHidden && + !isVirtual && + !isRelationship && + !dataViewDefaultFieldBlacklist.has(name.toLowerCase()) && + name !== table.idField.name && + name.toLowerCase() !== hiddenAuditTimestampField?.toLowerCase() + ) + .map(({ name }) => + serializeResource( + QueryFieldSpec.fromPath(table.name, [name]).toSpQueryField() + ) + ); + + if (hiddenAuditTimestampField !== undefined) { + const hiddenAuditField = QueryFieldSpec.fromPath(table.name, [ + hiddenAuditTimestampField, + ]).toSpQueryField(); + hiddenAuditField.set('sortType', 2); + hiddenAuditField.set('isDisplay', false); + fields.push(serializeResource(hiddenAuditField)); + } + + return { + fields, + selectDistinct: false, + searchSynonymy: false, + smushed: false, + }; +} + +export const dataViewDefaultFieldBlacklist = new Set([ + 'id', + 'timestampcreated', + 'timestampmodified', + 'version', +]); + +export function getDataViewQueryDefinition( + file: DataViewQueriesFile, + tableName: keyof Tables +): DataViewQueryDefinition { + return ( + getStoredDataViewQueryDefinition(file, tableName) ?? + defaultDataViewQuery(tableName) + ); +} + +export function getStoredDataViewQueryDefinition( + file: DataViewQueriesFile, + tableName: keyof Tables +): DataViewQueryDefinition | undefined { + const definition: unknown = file.queries[tableName]; + return isDataViewQueryDefinition(definition) ? definition : undefined; +} + +export function makeDataViewQuery( + tableName: keyof Tables, + definition: DataViewQueryDefinition +): SpecifyResource { + const table = strictGetTable(tableName); + const query = createQuery(`Data View: ${table.label}`, table); + query.set( + 'fields', + definition.fields.map((field) => addMissingFields('SpQueryField', field)) + ); + query.set('selectDistinct', definition.selectDistinct ?? false); + query.set('searchSynonymy', definition.searchSynonymy ?? false); + query.set('smushed', definition.smushed ?? false); + query.set('countOnly', false); + return query; +} + +/** Fetches the context-resolved resource, including discipline and defaults. */ +export function useDataViewQueries(): [ + DataViewQueriesFile | undefined, + () => void, +] { + const [reload, setReload] = React.useState(0); + const [data] = useAsyncState( + React.useCallback( + async () => + ajax( + `${getAppResourceUrl(dataViewQueriesResourceName, 'quiet')}&dataViewReload=${reload}`, + { + cache: 'no-store', + headers: { Accept: 'application/json' }, + errorMode: 'silent', + expectedErrors: [Http.NO_CONTENT, Http.NOT_FOUND], + } + ).then(({ data }) => parseDataViewQueries(data)), + [reload] + ), + true + ); + return [data, (): void => setReload((value) => value + 1)]; +} + +export async function saveUserDataViewQueries( + data: string, + tableName: keyof Tables +): Promise { + const resources = await ajax< + RA<{ + readonly id: number; + readonly name: string; + readonly mimetype: string; + }> + >('/context/user_resource/', { headers: { Accept: 'application/json' } }); + const [resource, ...duplicates] = resources.data.filter( + ({ name, mimetype }) => + name === dataViewQueriesResourceName && mimetype === 'application/json' + ); + const existingData = + resource === undefined + ? undefined + : await ajax<{ readonly data?: string }>( + `/context/user_resource/${resource.id}/`, + { headers: { Accept: 'application/json' } } + ).then(({ data: resourceData }) => resourceData.data); + const incoming = parseDataViewQueries(data); + const current = parseDataViewQueries(existingData); + const editedQuery = incoming.queries[tableName]; + const mergedData = serializeDataViewQueries({ + version: 1, + queries: { + ...current.queries, + ...(editedQuery === undefined ? {} : { [tableName]: editedQuery }), + }, + }); + const payload = keysToLowerCase({ + name: dataViewQueriesResourceName, + mimetype: 'application/json', + metadata: '', + data: mergedData, + }); + await ping( + resource === undefined + ? '/context/user_resource/' + : `/context/user_resource/${resource.id}/`, + { + method: resource === undefined ? 'POST' : 'PUT', + body: payload, + } + ); + await Promise.all( + duplicates.map(async ({ id }) => + ping(`/context/user_resource/${id}/`, { method: 'DELETE' }) + ) + ); + await clearUrlCache(getAppResourceUrl(dataViewQueriesResourceName)); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx index 4094c57a5a7..073745d6f2f 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/ExpressSearchTask.tsx @@ -310,6 +310,24 @@ function TableResult({ [ajaxUrl, table.name] ); + const fetchCount = React.useCallback( + async (): Promise => + ajax | QueryTableResult>( + formatUrl(ajaxUrl, { + name: table.name, + ...parseUrl(ajaxUrl), + }), + { + headers: { Accept: 'application/json' }, + } + ).then(({ data }) => + table.name in data + ? (data as IR)[table.name].totalCount + : (data as QueryTableResult).totalCount + ), + [ajaxUrl, table.name] + ); + const fieldSpecs = React.useMemo( () => tableResults.fieldSpecs.map(({ stringId, isRelationship }) => @@ -351,6 +369,7 @@ function TableResult({ createRecordSet={undefined} displayedFields={allFields} extraButtons={undefined} + fetchCount={fetchCount} fetchResults={handleFetch} fetchSize={expressSearchFetchSize} fieldSpecs={fieldSpecs} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 375843c28e9..0aba30fab11 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -19,6 +19,7 @@ import { reportsText } from '../../localization/report'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; import { statsText } from '../../localization/stats'; +import { treeText } from '../../localization/tree'; import type { Language } from '../../localization/utils/config'; import { LANGUAGE } from '../../localization/utils/config'; import { wbPlanText } from '../../localization/wbPlan'; @@ -1795,6 +1796,30 @@ export const userPreferenceDefinitions = { general: { title: preferencesText.general(), items: { + splitViewByDefault: definePref({ + title: dataViewsText.splitViewByDefault({ + splitView: treeText.splitView(), + }), + description: dataViewsText.splitViewDescription({ + splitView: treeText.splitView(), + }), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + splitViewOrientation: definePref<'horizontal' | 'vertical'>({ + title: dataViewsText.splitViewOrientation({ + splitView: treeText.splitView(), + }), + requiresReload: false, + visible: true, + defaultValue: 'horizontal', + values: [ + { value: 'horizontal', title: treeText.horizontal() }, + { value: 'vertical', title: treeText.vertical() }, + ], + }), noRestrictionsMode: definePref({ title: preferencesText.noRestrictionsMode(), description: ( @@ -1928,6 +1953,30 @@ export const userPreferenceDefinitions = { general: { title: preferencesText.general(), items: { + splitViewByDefault: definePref({ + title: dataViewsText.splitViewByDefault({ + splitView: treeText.splitView(), + }), + description: dataViewsText.splitViewDescription({ + splitView: treeText.splitView(), + }), + requiresReload: false, + visible: true, + defaultValue: true, + type: 'java.lang.Boolean', + }), + splitViewOrientation: definePref<'horizontal' | 'vertical'>({ + title: dataViewsText.splitViewOrientation({ + splitView: treeText.splitView(), + }), + requiresReload: false, + visible: true, + defaultValue: 'horizontal', + values: [ + { value: 'horizontal', title: treeText.horizontal() }, + { value: 'vertical', title: treeText.vertical() }, + ], + }), shownTables: definePref>({ title: localized('_shownTables'), requiresReload: false, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx index a4c09d0b0a5..12a3d939fac 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx @@ -26,6 +26,7 @@ import { useQueryViewPref } from './Context'; import { QueryEditButton } from './Edit'; import { QueryLoanReturn } from './LoanReturn'; import type { MainState } from './reducer'; +import { SplitViewOrientationButton, SplitViewToggleButton } from './SplitView'; export type QueryView = { readonly basicView: RA; @@ -45,6 +46,11 @@ export function QueryHeader({ unsetUnloadProtect, onTriedToSave: handleTriedToSave, onSaved: handleSaved, + isSplit, + canSplit, + isHorizontal, + onToggleSplit, + onToggleOrientation, }: { readonly recordSet?: SpecifyResource; readonly query: SerializedResource; @@ -60,6 +66,11 @@ export function QueryHeader({ readonly unsetUnloadProtect: () => void; readonly onTriedToSave: () => void; readonly onSaved: () => void; + readonly isSplit: boolean; + readonly canSplit: boolean; + readonly isHorizontal: boolean; + readonly onToggleSplit: () => void; + readonly onToggleOrientation: () => void; }): JSX.Element { // Detects any query being deleted and updates it every where and redirect const navigate = useNavigate(); @@ -122,6 +133,17 @@ export function QueryHeader({ /> ) : undefined} + + +
setIsBasic(!isBasic)}> {isBasic diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx new file mode 100644 index 00000000000..413e06eb367 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { localized, type RA } from '../../utils/types'; +import { BatchEditFromQuery } from '../BatchEdit'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import type { RecordSet, SpQuery, SpQueryField } from '../DataModel/types'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { hasPermission } from '../Permissions/helpers'; +import { datasetVariants } from '../WbUtils/datasetVariants'; +import { MakeRecordSetButton } from './Components'; +import { QueryExportButtons } from './Export'; +import type { QueryField } from './helpers'; +import type { MainState } from './reducer'; +import type { QueryResultRow } from './Results'; +import { QueryResultsWrapper } from './ResultsWrapper'; + +export function QueryBuilderResults({ + table, + query, + queryResource, + recordSet, + forceCollection, + state, + isReadOnly, + saveRequired, + getQueryFieldRecords, + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + resultsRef, + isSplit, + isHorizontal, + onReRun: handleReRun, + onRunQuery: handleRunQuery, + onSelected: handleSelected, + onSortChange: handleSortChange, +}: { + readonly table: SpecifyTable; + readonly query: SerializedResource; + readonly queryResource: SpecifyResource; + readonly recordSet: SpecifyResource | undefined; + readonly forceCollection: number | undefined; + readonly state: MainState; + readonly isReadOnly: boolean; + readonly saveRequired: boolean; + readonly getQueryFieldRecords: + | (() => RA>) + | undefined; + readonly selectedRows: ReadonlySet; + readonly setSelectedRows: React.Dispatch< + React.SetStateAction> + >; + readonly selectedIndex: number; + readonly setSelectedIndex: React.Dispatch>; + readonly resultsRef: React.MutableRefObject< + RA | undefined + >; + readonly isSplit: boolean; + readonly isHorizontal: boolean; + readonly onReRun: () => void; + readonly onRunQuery: (fields?: RA) => void; + readonly onSelected: (ids: RA) => void; + readonly onSortChange: (fields: RA) => void; +}): JSX.Element | null { + const selectedIds = React.useMemo( + () => Array.from(selectedRows), + [selectedRows] + ); + const recordPreview = ( +
+ {selectedIds.length === 0 ? ( +

{commonText.select()}

+ ) : ( + { + setSelectedRows(new Set()); + setSelectedIndex(0); + }} + onDelete={undefined} + onSaved={(): void => handleRunQuery()} + onSlide={setSelectedIndex} + /> + )} +
+ ); + + return hasPermission('/querybuilder/query', 'execute') ? ( + + ) : undefined + } + extraButtons={ + <> + {datasetVariants.batchEdit.canCreate() && ( + + )} + {query.countOnly ? undefined : ( + + )} + + } + fields={state.fields} + forceCollection={forceCollection} + queryResource={queryResource} + queryRunCount={state.queryRunCount} + recordSetId={recordSet?.id} + resultsRef={resultsRef} + selectedRows={[selectedRows, setSelectedRows]} + splitHorizontal={isHorizontal} + splitPane={isSplit ? recordPreview : undefined} + table={table} + onReRun={handleReRun} + onSelected={handleSelected} + onSortChange={handleSortChange} + /> + ) : null; +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index d031eadf6c9..d19840b79aa 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -71,6 +71,7 @@ export type QueryResultsProps = { readonly fetchResults: | ((offset: number) => Promise>) | undefined; + readonly fetchCount: (() => Promise) | undefined; readonly totalCount: number | undefined; readonly fieldSpecs: RA; readonly displayedFields: RA; @@ -86,8 +87,13 @@ export type QueryResultsProps = { readonly onReRun: () => void; readonly createRecordSet: JSX.Element | undefined; readonly extraButtons: JSX.Element | undefined; + readonly containerClassName?: string; readonly tableClassName?: string; readonly selectedRows: GetSet>; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; + readonly refreshToken?: number; readonly resultsRef?: React.MutableRefObject< RA | undefined >; @@ -98,6 +104,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { table, label = commonText.results(), queryResource, + fetchCount, fetchResults, fieldSpecs, allFields, @@ -108,8 +115,13 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { onReRun: handleReRun, createRecordSet, extraButtons, + containerClassName = '', tableClassName = '', selectedRows: [selectedRows, setSelectedRows], + onResults: handleResults, + scrollRef, + restoreScrollTopRef, + refreshToken, resultsRef, displayedFields, } = props; @@ -125,6 +137,70 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { fetchSize: props.fetchSize, totalCount: props.totalCount, }); + const currentResultsRef = React.useRef(results); + currentResultsRef.current = results; + const previousRefreshToken = React.useRef(refreshToken); + const refreshGenerationRef = React.useRef(0); + + React.useEffect(() => { + if ( + refreshToken === undefined || + refreshToken === previousRefreshToken.current + ) + return; + previousRefreshToken.current = refreshToken; + const currentResults = currentResultsRef.current; + if ( + !Array.isArray(currentResults) || + fetchCount === undefined || + fetchResults === undefined + ) + return; + + const generation = ++refreshGenerationRef.current; + fetchCount() + .then(async (refreshedTotalCount) => { + if (generation !== refreshGenerationRef.current) return; + setTotalCount(refreshedTotalCount); + const offsets = Array.from( + { + length: Math.min( + Math.ceil(currentResults.length / props.fetchSize), + Math.ceil(refreshedTotalCount / props.fetchSize) + ), + }, + (_, index) => index * props.fetchSize + ); + const pages = await Promise.all( + offsets.map((offset) => fetchResults(offset)) + ); + if (generation !== refreshGenerationRef.current) return; + const refreshedResults = currentResults.slice(); + let refreshedResultCount = refreshedTotalCount; + // Stop applying pages once a short page is hit, so a later full page + // can't re-extend the array past the earliest known end of data + for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { + const page = pages[pageIndex]; + const offset = offsets[pageIndex]; + refreshedResults.splice(offset, page.length, ...page); + if (page.length < props.fetchSize) { + refreshedResultCount = offset + page.length; + break; + } + } + refreshedResults.length = refreshedResultCount; + setTotalCount(refreshedTotalCount); + setResults(refreshedResults); + }) + .catch(() => undefined); + }, [ + fetchCount, + fetchResults, + props.fetchSize, + refreshToken, + setResults, + setTotalCount, + ]); const canMergeTable = canMerge(table); @@ -139,6 +215,25 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ); if (resultsRef !== undefined) resultsRef.current = results; + React.useEffect(() => { + if (results !== undefined) handleResults?.(results); + }, [handleResults, results]); + + React.useEffect(() => { + const scrollTop = restoreScrollTopRef?.current; + if ( + scrollTop === undefined || + results === undefined || + restoreScrollTopRef === undefined + ) + return; + restoreScrollTopRef.current = undefined; + requestAnimationFrame(() => { + if (scrollRef?.current !== null && scrollRef?.current !== undefined) + scrollRef.current.scrollTop = scrollTop; + }); + }, [results, restoreScrollTopRef, scrollRef]); + const [pickListsLoaded = false] = useAsyncState( React.useCallback( async () => @@ -170,8 +265,15 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { const [showCellEllipsis, setShowCellEllipsis] = React.useState(false); const lastSelectedRow = React.useRef(undefined); - // Unselect all rows when query is reRun - React.useEffect(() => setSelectedRows(new Set()), [fieldSpecs]); + // Unselect all rows when the query fields change, but do not clear the + // parent-owned selection when this component is remounted while changing + // split-view orientation. + const previousFieldSpecs = React.useRef(fieldSpecs); + React.useEffect(() => { + if (previousFieldSpecs.current === fieldSpecs) return; + previousFieldSpecs.current = fieldSpecs; + setSelectedRows(new Set()); + }, [fieldSpecs, setSelectedRows]); const showResults = Array.isArray(results) && @@ -347,7 +449,9 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { const metaColumns = (showLineNumber ? 1 : 0) + 2; return ( - +

{commonText.colonLine({ @@ -452,7 +556,10 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ${tableClassName} ${showResults ? 'border-b border-gray-500' : ''} `} - ref={scrollerRef} + ref={(element): void => { + scrollerRef.current = element; + if (scrollRef !== undefined) scrollRef.current = element; + }} role="table" style={{ gridTemplateColumns: [ @@ -551,6 +658,12 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { lastSelectedRow.current = rowIndex; }} + onRowSelected={(rowIndex): void => { + const id = loadedResults[rowIndex][queryIdField] as number; + setSelectedRows(new Set([id])); + handleSelected?.([id]); + lastSelectedRow.current = rowIndex; + }} /> ) : undefined} {isFetching || (!showResults && Array.isArray(results)) ? ( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx index 62445d877d1..0b13682d3cb 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx @@ -35,6 +35,7 @@ export function QueryResultsTable({ wrapQueryResults, selectedRows, onSelected: handleSelected, + onRowSelected: handleRowSelected, }: { readonly table: SpecifyTable; readonly fieldSpecs: RA; @@ -47,6 +48,7 @@ export function QueryResultsTable({ isSelected: boolean, isShiftClick: boolean ) => void; + readonly onRowSelected: (index: number) => void; }): JSX.Element { const recordFormatter = React.useMemo( () => getAuditRecordFormatter(fieldSpecs), @@ -74,6 +76,7 @@ export function QueryResultsTable({ onSelected={(isSelected, isShiftClick): void => handleSelected(index, isSelected, isShiftClick) } + onRowSelected={(): void => handleRowSelected(index)} /> ))} @@ -91,6 +94,7 @@ function Row({ showCellEllipsis, wrapQueryResults, onSelected: handleSelected, + onRowSelected: handleRowSelected, }: { readonly table: SpecifyTable; readonly fieldSpecs: RA; @@ -104,6 +108,7 @@ function Row({ readonly showCellEllipsis: boolean; readonly wrapQueryResults: boolean; readonly onSelected?: (isSelected: boolean, isShiftClick: boolean) => void; + readonly onRowSelected?: () => void; }): JSX.Element { // REFACTOR: replace this with getResourceViewUrl() const [resource] = useLiveState< @@ -151,14 +156,14 @@ function Row({ `} role="row" onClick={ - typeof handleSelected === 'function' - ? ({ target, shiftKey }): void => + typeof handleRowSelected === 'function' + ? ({ target }): void => /* - * Ignore clicks on the "View" links and formatted audit log cell - * links + * Ignore clicks on the "View" links, formatted audit log cell + * links, and record-list controls (e.g., the split records button) */ - (target as Element).closest('a') === null - ? handleSelected?.(!isSelected, shiftKey) + (target as Element).closest('a, button') === null + ? handleRowSelected?.() : undefined : undefined } @@ -181,12 +186,18 @@ function Row({ className={`${getCellClassName(condenseQueryResults)} sticky`} role="cell" > - +
{ + event.stopPropagation(); + handleSelected?.(!isSelected, event.shiftKey); + }} + > + +

) => void; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; + readonly refreshToken?: number; + readonly splitPane?: JSX.Element; + readonly splitHorizontal?: boolean; readonly onReRun: () => void; }): JSX.Element | null { const newProps = useQueryResultsWrapper(props); - return newProps === undefined ? ( - props.queryRunCount === 0 ? null : ( + if (newProps === undefined) + return props.queryRunCount === 0 ? null : (
{loadingGif}
- ) - ) : ( + ); + + const queryResults = (
); + + return splitPane === undefined ? ( + queryResults + ) : ( + + ); } type ResultsProps = { @@ -77,6 +103,11 @@ type ResultsProps = { newFields: RA ) => void; readonly selectedRows: GetSet>; + readonly containerClassName?: string; + readonly tableClassName?: string; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; readonly resultsRef?: React.MutableRefObject< RA | undefined >; @@ -109,6 +140,25 @@ export const runQuery = async ( }), }).then(({ data }) => data.results); +const runQueryCount = async ( + query: SerializedRecord | SerializedResource, + extras: Partial<{ + readonly collectionId: number; + readonly limit: number; + readonly recordSetId: number; + }> = {} +): Promise => + ajax<{ readonly count: number }>('/stored_query/ephemeral/', { + method: 'POST', + errorMode: 'dismissible', + headers: { Accept: 'application/json' }, + body: keysToLowerCase({ + ...query, + ...extras, + countOnly: true, + }), + }).then(({ data }) => data.count); + /** * Extracting the logic into a hook so that can be reused even outside the * Query Builder (in the Specify Network) @@ -120,8 +170,13 @@ export function useQueryResultsWrapper({ fields, recordSetId, forceCollection, + containerClassName, + tableClassName, onSortChange: handleSortChange, + onResults: handleResults, selectedRows: [selectedRows, setSelectedRows], + scrollRef, + restoreScrollTopRef, resultsRef, }: ResultsProps): PartialProps | undefined { /* @@ -162,18 +217,9 @@ export function useQueryResultsWrapper({ }; setTotalCount(undefined); - ajax<{ readonly count: number }>('/stored_query/ephemeral/', { - method: 'POST', - errorMode: 'dismissible', - headers: { Accept: 'application/json' }, - body: keysToLowerCase({ - ...query, - ...fetchPayload, - countOnly: true, - }), - }) - .then(({ data }) => setTotalCount(data.count)) - .catch(raise); + const fetchCount = async (): Promise => + runQueryCount(query, fetchPayload); + fetchCount().then(setTotalCount).catch(raise); const displayedFields = allFields.filter((field) => field.isDisplay); const countOnly = queryResource.get('countOnly') === true; @@ -198,15 +244,21 @@ export function useQueryResultsWrapper({ .then((initialData) => setProps({ queryResource, + containerClassName, + tableClassName, fetchSize, table, fetchResults: isCountOnly ? undefined : async (offset) => runQuery(query, { ...fetchPayload, offset }), + fetchCount, allFields, displayedFields: queryFields, fieldSpecs, initialData, + onResults: handleResults, + scrollRef, + restoreScrollTopRef, sortConfig: queryFields .filter(({ isDisplay }) => isDisplay) .map((field) => field.sortType), diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx new file mode 100644 index 00000000000..1b8e8942996 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import Splitter from 'm-react-splitters'; + +import { Button } from '../Atoms/Button'; +import { treeText } from '../../localization/tree'; + +export function useSplitViewOrientation(defaultHorizontal = true): { + readonly isHorizontal: boolean; + readonly toggleOrientation: () => void; +} { + const [isHorizontal, setIsHorizontal] = React.useState(defaultHorizontal); + return { + isHorizontal, + toggleOrientation: (): void => setIsHorizontal((horizontal) => !horizontal), + }; +} + +export function SplitViewOrientationButton({ + isHorizontal, + disabled = false, + onToggle: handleToggle, +}: { + readonly isHorizontal: boolean; + readonly disabled?: boolean; + readonly onToggle: () => void; +}): JSX.Element { + return ( + + ); +} + +export function SplitViewToggleButton({ + isSplit, + disabled = false, + onToggle: handleToggle, +}: { + readonly isSplit: boolean; + readonly disabled?: boolean; + readonly onToggle: () => void; +}): JSX.Element { + return ( + + ); +} + +export function SplitView({ + primaryPane, + secondaryPane, + primaryPaneKey, + secondaryPaneKey, + isHorizontal, +}: { + readonly primaryPane: JSX.Element; + readonly secondaryPane: JSX.Element; + readonly primaryPaneKey: string; + readonly secondaryPaneKey: string; + readonly isHorizontal: boolean; +}): JSX.Element { + return ( + +
+ {primaryPane} +
+
+ {secondaryPane} +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index 7af71e77d03..04dde28301c 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -84,7 +84,16 @@ export function QueryToolbar({ {queryText.countOnly()} - + { + // QueryBuilder can be embedded inside another form (for example + // the DataViewQueries app resource editor). Do not let this + // submit that parent form and reload the page. + event.preventDefault(); + event.stopPropagation(); + handleSubmitClick(); + }} + > {queryText.query()} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 80c360c86de..2de5fb89613 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -17,7 +17,6 @@ import { Container } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; -import { BatchEditFromQuery } from '../BatchEdit'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -41,19 +40,18 @@ import { } from '../WbPlanView/mappingHelpers'; import { getMappingLineData } from '../WbPlanView/navigator'; import { navigatorSpecs } from '../WbPlanView/navigatorSpecs'; -import { datasetVariants } from '../WbUtils/datasetVariants'; import { CheckReadAccess } from './CheckReadAccess'; -import { MakeRecordSetButton } from './Components'; import { IsQueryBasicContext, useQueryViewPref } from './Context'; -import { QueryExportButtons } from './Export'; import { QueryFields } from './Fields'; import { QueryFromMap } from './FromMap'; import { QueryHeader } from './Header'; import { unParseQueryFields } from './helpers'; import { getInitialState, reducer } from './reducer'; import type { QueryResultRow } from './Results'; -import { QueryResultsWrapper } from './ResultsWrapper'; +import { QueryBuilderResults } from './QueryBuilderResults'; import { QueryToolbar } from './Toolbar'; +import { useQueryExecution } from './useQueryExecution'; +import { useQuerySplitView } from './useQuerySplitView'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -76,7 +74,6 @@ export function QueryBuilder( return treeRanksLoaded ? : ; } -// REFACTOR: split this component function Wrapped({ query: queryResource, recordSet, @@ -106,9 +103,6 @@ function Wrapped({ const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, false); const table = getTableById(query.contextTableId); - const [selectedRows, setSelectedRows] = React.useState>( - new Set() - ); const buildInitialState = React.useCallback( () => @@ -159,6 +153,7 @@ function Wrapped({ React.useEffect(checkForChanges, [state.fields]); React.useEffect(() => { + if (state === pendingState) return; handleChange?.({ fields: unParseQueryFields(state.baseTableName, state.fields), isDistinct: query.selectDistinct, @@ -210,37 +205,25 @@ function Wrapped({ ], }); + const serializeQueryFields = ( + fields: typeof state.fields = state.fields + ): ReturnType => + unParseQueryFields(state.baseTableName, fields); + /* * That function does not need to be called most of the time if query * fields haven't changed yet. This avoids triggering needless save blocker */ - const getQueryFieldRecords = saveRequired - ? ( - fields: typeof state.fields = state.fields - ): ReturnType => - unParseQueryFields(state.baseTableName, fields) - : undefined; + const getQueryFieldRecords = saveRequired ? serializeQueryFields : undefined; - /* - * REFACTOR: simplify this (move "executed query" state into this component - * and get rid of queryRunCount) - */ - function runQuery( - mode: 'count' | 'regular', - fields: typeof state.fields = state.fields - ): void { - if (!hasPermission('/querybuilder/query', 'execute')) return; - setQuery({ - ...query, - fields: getQueryFieldRecords?.(fields) ?? query.fields, - countOnly: mode === 'count', - }); - /* - * Wait for new query to propagate before re running it - * TEST: check if this still works after updating to React 18 - */ - globalThis.setTimeout(() => dispatch({ type: 'RunQueryAction' }), 0); - } + // runQuery must always serialize the fields it is given, not just when saveRequired + const { runQuery, scheduleQueryRun } = useQueryExecution({ + query, + fields: state.fields, + getQueryFieldRecords: serializeQueryFields, + setQuery, + onRun: (): void => dispatch({ type: 'RunQueryAction' }), + }); /* * Require only one of these permissions as query builder could be useful with @@ -273,16 +256,6 @@ function Wrapped({ useTitle(localized(query.name)); - const [isQueryRunPending, handleQueryRunPending, handleNoQueryRunPending] = - useBooleanState(); - React.useEffect(() => { - if (!isQueryRunPending) return; - handleNoQueryRunPending(); - runQuery('regular'); - // Only reRun when isQueryRunPending is true, not when runQuery changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isQueryRunPending, handleNoQueryRunPending]); - const [isScrolledTop, handleScrollTop, handleScrolledDown] = useBooleanState(true); @@ -302,6 +275,17 @@ function Wrapped({ const resultsRef = React.useRef | undefined>( undefined ); + const { + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + isSplit, + canSplit, + isHorizontal, + toggleSplit, + toggleOrientation, + } = useQuerySplitView(resultsRef); const showSeries = React.useMemo( () => @@ -374,6 +358,11 @@ function Wrapped({ dispatch({ type: 'SavedQueryAction' }); }} onTriedToSave={handleTriedToSave} + isSplit={isSplit} + canSplit={canSplit} + isHorizontal={isHorizontal} + onToggleSplit={toggleSplit} + onToggleOrientation={toggleOrientation} />
@@ -586,11 +575,7 @@ function Wrapped({ showSeries={showSeries} tableName={table.name} onRunCountOnly={(): void => runQuery('count')} - onSubmitClick={(): void => - form?.checkValidity() === false - ? runQuery('regular') - : undefined - } + onSubmitClick={(): void => runQuery('regular')} onToggleDistinct={(): void => { setQuery({ ...query, @@ -614,68 +599,34 @@ function Wrapped({ }} />
- {hasPermission('/querybuilder/query', 'execute') && ( - - ) : undefined - } - extraButtons={ - <> - {datasetVariants.batchEdit.canCreate() && ( - - )} - {query.countOnly ? undefined : ( - - )} - - } - fields={state.fields} - forceCollection={forceCollection} - queryResource={queryResource} - queryRunCount={state.queryRunCount} - recordSetId={recordSet?.id} - resultsRef={resultsRef} - selectedRows={[selectedRows, setSelectedRows]} - table={table} - onReRun={(): void => - dispatch({ - type: 'RunQueryAction', - }) - } - onSelected={handleSelected} - onSortChange={(fields): void => { - dispatch({ - type: 'ChangeFieldsAction', - fields, - }); - runQuery('regular', fields); - }} - /> - )} + dispatch({ type: 'RunQueryAction' })} + onRunQuery={(fields): void => runQuery('regular', fields)} + onSelected={(ids): void => { + setSelectedIndex(Math.max(0, ids.length - 1)); + handleSelected?.(ids); + }} + onSortChange={(fields): void => { + dispatch({ type: 'ChangeFieldsAction', fields }); + runQuery('regular', fields); + }} + /> diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx new file mode 100644 index 00000000000..cdc86505744 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react'; + +import { hasPermission } from '../../Permissions/helpers'; +import type { SerializedResource } from '../../DataModel/helperTypes'; +import type { SpQuery, SpQueryField } from '../../DataModel/types'; +import type { QueryField } from '../helpers'; +import { useQueryExecution } from '../useQueryExecution'; + +jest.mock('../../Permissions/helpers', () => ({ + hasPermission: jest.fn(() => true), +})); + +const query = { fields: [] } as unknown as SerializedResource; +const fields = [] as const as readonly QueryField[]; +const serializedFields = [ + { fieldName: 'Name' }, +] as unknown as readonly SerializedResource[]; + +afterEach(() => jest.useRealTimers()); + +test('serializes the current fields and defers an authorized query run', () => { + jest.useFakeTimers(); + const setQuery = jest.fn(); + const onRun = jest.fn(); + const getQueryFieldRecords = jest.fn(() => serializedFields); + const { result } = renderHook(() => + useQueryExecution({ + query, + fields, + getQueryFieldRecords, + setQuery, + onRun, + }) + ); + + act(() => result.current.runQuery('count')); + + expect(hasPermission).toHaveBeenCalledWith('/querybuilder/query', 'execute'); + expect(getQueryFieldRecords).toHaveBeenCalledWith(fields); + expect(setQuery).toHaveBeenCalledWith({ + ...query, + fields: serializedFields, + countOnly: true, + }); + expect(onRun).not.toHaveBeenCalled(); + + act(() => jest.runOnlyPendingTimers()); + + expect(onRun).toHaveBeenCalledTimes(1); +}); + +test('schedules a regular query run after pending input changes', () => { + jest.useFakeTimers(); + const setQuery = jest.fn(); + const onRun = jest.fn(); + const { result } = renderHook(() => + useQueryExecution({ + query, + fields, + getQueryFieldRecords: undefined, + setQuery, + onRun, + }) + ); + + act(() => result.current.scheduleQueryRun()); + + expect(setQuery).toHaveBeenCalledWith({ + ...query, + countOnly: false, + }); + + act(() => jest.runOnlyPendingTimers()); + + expect(onRun).toHaveBeenCalledTimes(1); +}); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts new file mode 100644 index 00000000000..d36bc5047af --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts @@ -0,0 +1,53 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpQuery, SpQueryField } from '../DataModel/types'; +import { hasPermission } from '../Permissions/helpers'; +import type { RA } from '../../utils/types'; +import type { QueryField } from './helpers'; + +export function useQueryExecution({ + query, + fields, + getQueryFieldRecords, + setQuery, + onRun, +}: { + readonly query: SerializedResource; + readonly fields: RA; + readonly getQueryFieldRecords: + | ((fields: RA) => RA>) + | undefined; + readonly setQuery: (query: SerializedResource) => void; + readonly onRun: () => void; +}): { + readonly runQuery: ( + mode: 'count' | 'regular', + fields?: RA + ) => void; + readonly scheduleQueryRun: () => void; +} { + const [isQueryRunPending, scheduleQueryRun, clearQueryRunPending] = + useBooleanState(); + const runQuery = React.useCallback( + (mode: 'count' | 'regular', queryFields: RA = fields): void => { + if (!hasPermission('/querybuilder/query', 'execute')) return; + setQuery({ + ...query, + fields: getQueryFieldRecords?.(queryFields) ?? query.fields, + countOnly: mode === 'count', + }); + globalThis.setTimeout(onRun, 0); + }, + [fields, getQueryFieldRecords, onRun, query, setQuery] + ); + + React.useEffect(() => { + if (!isQueryRunPending) return; + clearQueryRunPending(); + runQuery('regular'); + }, [clearQueryRunPending, isQueryRunPending, runQuery]); + + return { runQuery, scheduleQueryRun }; +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts new file mode 100644 index 00000000000..d74ba0d1ec0 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts @@ -0,0 +1,89 @@ +import React from 'react'; + +import type { RA } from '../../utils/types'; +import { listen } from '../../utils/events'; +import { queryIdField, type QueryResultRow } from './Results'; +import { useSplitViewOrientation } from './SplitView'; +import { userPreferences } from '../Preferences/userPreferences'; + +const SMALL_SCREEN_WIDTH = 768; + +export function useQuerySplitView( + resultsRef: React.MutableRefObject | undefined> +): { + readonly selectedRows: ReadonlySet; + readonly setSelectedRows: React.Dispatch< + React.SetStateAction> + >; + readonly selectedIndex: number; + readonly setSelectedIndex: React.Dispatch>; + readonly isSplit: boolean; + readonly canSplit: boolean; + readonly isHorizontal: boolean; + readonly toggleSplit: () => void; + readonly toggleOrientation: () => void; +} { + const [selectedRows, setSelectedRows] = React.useState>( + new Set() + ); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [splitViewByDefault] = userPreferences.use( + 'queryBuilder', + 'general', + 'splitViewByDefault' + ); + const [splitViewOrientation] = userPreferences.use( + 'queryBuilder', + 'general', + 'splitViewOrientation' + ); + const [rawIsSplit, setIsSplit] = React.useState(splitViewByDefault); + const [canSplit, setCanSplit] = React.useState( + window.innerWidth >= SMALL_SCREEN_WIDTH + ); + React.useEffect(() => { + const handleResize = (): void => + setCanSplit(window.innerWidth >= SMALL_SCREEN_WIDTH); + handleResize(); + return listen(window, 'resize', handleResize); + }, []); + const isSplit = rawIsSplit && canSplit; + const { isHorizontal, toggleOrientation } = useSplitViewOrientation( + splitViewOrientation === 'horizontal' + ); + + const selectFirstResult = React.useCallback((): boolean => { + const firstId = resultsRef.current?.find( + (result) => result !== undefined + )?.[queryIdField]; + if (typeof firstId !== 'number') return false; + setSelectedRows(new Set([firstId])); + setSelectedIndex(0); + return true; + }, [resultsRef]); + + React.useEffect(() => { + if (!isSplit || selectedRows.size > 0 || selectFirstResult()) return; + const interval = setInterval(() => { + if (selectFirstResult()) clearInterval(interval); + }, 200); + return (): void => clearInterval(interval); + }, [isSplit, selectedRows.size, selectFirstResult]); + + const toggleSplit = (): void => { + const nextIsSplit = !isSplit; + setIsSplit(nextIsSplit); + if (nextIsSplit && selectedRows.size === 0) selectFirstResult(); + }; + return { + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + isSplit, + canSplit, + isHorizontal, + toggleSplit, + toggleOrientation, + }; +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index 32771d78b08..b898b1df421 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -1,13 +1,9 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; -import { H3 } from '../Atoms'; -import { Button } from '../Atoms/Button'; -import { Input } from '../Atoms/Form'; import { useSchemaConfig } from './Store'; -import { TableList, tablesFilter } from './Tables'; +import { CollapsibleTableList } from './Tables'; export function SchemaConfigSidebar({ tableName, @@ -16,61 +12,27 @@ export function SchemaConfigSidebar({ }): JSX.Element { const { language = '' } = useParams(); const { modifiedTables } = useSchemaConfig(); - const [search, setSearch] = React.useState(''); - const [isCollapsed, setIsCollapsed] = React.useState(false); - return isCollapsed ? ( - - ) : ( - + return ( + + modifiedTables.includes(table.name) ? ( + + * + + ) : undefined + } + cacheKey="schemaConfig" + currentTableName={tableName} + getAction={(table): string => + `/specify/schema-config/${language}/${table.name}/` + } + localizeTableNames={false} + /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx index 078fd597891..b6bd59ccf5d 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { useCachedState } from '../../hooks/useCachedState'; +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; import { wbPlanText } from '../../localization/wbPlan'; import type { CacheDefinitions } from '../../utils/cache/definitions'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; -import { Ul } from '../Atoms'; +import { H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; @@ -135,11 +137,16 @@ export function TableList({ return isVisible ? (
  • {typeof action === 'function' ? ( - {content} + + {content} + ) : ( @@ -160,3 +167,84 @@ export function TableList({
  • ); } + +/** + * A `TableList` wrapped in a collapsible, searchable panel. + * Used by both the schema config sidebar and the data view query editor. + */ +export function CollapsibleTableList({ + cacheKey, + getAction, + filter: extraFilter, + localizeTableNames = true, + currentTableName, + badge, + asAside = false, +}: { + readonly cacheKey: CacheKey; + readonly getAction: (table: SpecifyTable) => string | (() => void); + readonly filter?: (table: SpecifyTable) => boolean; + readonly localizeTableNames?: boolean; + readonly currentTableName?: string; + readonly badge?: (table: SpecifyTable) => React.ReactNode; + // Use