From 419e37c0e25103f3f2ffad9a2d0e4aa1e715ef9d Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 5 Feb 2025 09:47:56 -0500 Subject: [PATCH 01/43] Enable trees in queries --- .../js_src/lib/components/BatchEdit/index.tsx | 11 +++-------- .../lib/components/WbPlanView/navigatorSpecs.ts | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index af43b365209..a1db4ba4359 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -96,8 +96,7 @@ export function BatchEditFromQuery({ { loading( @@ -167,11 +166,6 @@ const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) => queryFieldSpec.baseTable.name.toLowerCase() as 'collection' ); -const containsTreeTableOrSpecificRank = (queryFieldSpec: QueryFieldSpec) => - isTreeTable(queryFieldSpec.baseTable.name) || - (typeof queryFieldSpec.treeRank === 'string' && - queryFieldSpec.treeRank !== '-any'); - const filters = [containsFaultyNestedToMany, containsSystemTables]; const getTreeDefFromName = ( @@ -222,7 +216,8 @@ function findMissingRanks( ): RA { const allTreeDefItems = strictGetTreeDefinitionItems( treeTable.name as 'Geography', - false + false, + 'all' ); // Duplicates don't affect any logic here diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index 86c639f5167..f15346f1760 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -96,7 +96,7 @@ const queryBuilder: NavigatorSpec = { allowTransientToMany: true, useSchemaOverrides: false, // All tree fields are only available for "any rank" - includeAllTreeFields: false, + includeAllTreeFields: true, allowNestedToMany: true, ensurePermission: () => userPreferences.get('queryBuilder', 'general', 'showNoReadTables') From 93e2a0a91504f0a0ba2561bea70a02f4b540862c Mon Sep 17 00:00:00 2001 From: Sharad S Date: Fri, 7 Feb 2025 16:23:02 -0500 Subject: [PATCH 02/43] Use query construct code from #4929 --- specifyweb/stored_queries/query_construct.py | 69 +++++++++----------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index 6846b41fd50..cb9ccc9806d 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -18,10 +18,11 @@ def _safe_filter(query): return query.first() raise Exception(f"Got more than one matching: {list(query)}") -class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count internal_filters')): +class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count param_count internal_filters')): def __new__(cls, *args, **kwargs): kwargs['join_cache'] = dict() + kwargs['param_count'] = 0 # TODO: Use tree_rank_count to implement cases where formatter of taxon is defined with fields from the parent. # In that case, the cycle will end (unlike other cyclical cases). kwargs['tree_rank_count'] = 0 @@ -71,48 +72,40 @@ def handle_tree_field(self, node, table, tree_rank, next_join_path, current_fiel assert len(treedefs_with_ranks) >= 1, "Didn't find the tree rank across any tree" - column_name = next_join_path[0].name - - # NOTE: Code from #4929 - # def make_tree_field_spec(tree_node): - # return current_field_spec._replace( - # root_table=table, # rebasing the query - # root_sql_table=tree_node, # this is needed to preserve SQL aliased going to next part - # join_path=next_join_path, # slicing join path to begin from after the tree - # ) - - # cases = [] - # field = None # just to stop mypy from complaining. - # for ancestor in ancestors: - # field_spec = make_tree_field_spec(ancestor) - # query, orm_field, field, table = field_spec.add_spec_to_query(query) - # #field and table won't matter. rank acts as fork, and these two will be same across siblings - # cases.append((getattr(ancestor, treedefitem_column) == treedefitem_param, orm_field)) - - # column = sql.case(cases) - - # return query, column, field, table - - def _predicates_for_node(_node): - return [ - # TEST: consider taking the treedef_id comparison just to the first node, if it speeds things up (matching for higher is redundant..) - (sql.and_(getattr(_node, treedef_column)==treedef_id, getattr(_node, treedefitem_column)==treedefitem_id), getattr(_node, column_name)) - for (treedef_id, treedefitem_id) in treedefs_with_ranks - ] - - cases_per_ancestor = [ - _predicates_for_node(ancestor) - for ancestor in ancestors - ] - - column = sql.case([case for per_ancestor in cases_per_ancestor for case in per_ancestor]) + treedefitem_params = [] + for _, rank_id in treedefs_with_ranks: + treedefitem_param = sql.bindparam( + 'tdi_%s' % query.param_count, + value=rank_id + ) + treedefitem_params.append(treedefitem_param) + param_count = self.param_count + 1 + query = query._replace(param_count=param_count) + + def make_tree_field_spec(tree_node): + return current_field_spec._replace( + root_table=table, # rebasing the query + root_sql_table=tree_node, # this is needed to preserve SQL aliased going to next part + join_path=next_join_path, # slicing join path to begin from after the tree + ) + + cases = [] + field = None # just to stop mypy from complaining. + for ancestor in ancestors: + field_spec = make_tree_field_spec(ancestor) + query, orm_field, field, table = field_spec.add_spec_to_query(query) + # Field and table won't matter. Rank acts as fork, and these two will be same across siblings + for treedefitem_param in treedefitem_params: + cases.append((getattr(ancestor, treedefitem_column) == treedefitem_param, orm_field)) + + column = sql.case(cases) defs_to_filter_on = [def_id for (def_id, _) in treedefs_with_ranks] # We don't want to include treedef if the rank is not present. new_filters = [*query.internal_filters, getattr(node, treedef_column).in_(defs_to_filter_on)] query = query._replace(internal_filters=new_filters) - - return query, column, current_field_spec.get_field(), table + + return query, column, field, table def tables_in_path(self, table, join_path): path = deque(join_path) From cff6f560d6fd07e778454faa58ed7f2aa4049a73 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Fri, 7 Feb 2025 16:33:50 -0500 Subject: [PATCH 03/43] Update unit test --- .../WbPlanView/__tests__/navigator.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index f6487ecf342..f57d639e5fe 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -975,6 +975,160 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'Full Name', }, + acceptedChildren: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Synonyms', + tableName: 'Taxon', + }, + acceptedTaxon: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Preferred Taxon', + tableName: 'Taxon', + }, + children: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Child Taxa', + tableName: 'Taxon', + }, + commonName: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Common Name', + }, + definitionItem: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Taxonomic Rank', + tableName: 'TaxonTreeDefItem', + }, + determinations: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Determinations', + tableName: 'Determination', + }, + guid: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'GUID', + }, + hybridParent1: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Hybrid Parent1', + tableName: 'Taxon', + }, + hybridParent2: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Hybrid Parent2', + tableName: 'Taxon', + }, + isAccepted: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Is Preferred', + }, + isHybrid: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Is Hybrid', + }, + name: { + isDefault: true, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Name', + }, + parent: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Parent of Taxon', + tableName: 'Taxon', + }, + rankId: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Rank ID', + }, + remarks: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Remarks', + }, + source: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: false, + isRequired: false, + optionLabel: 'Source', + }, + taxonAttachments: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Taxon Attachments', + tableName: 'TaxonAttachment', + }, + taxonCitations: { + isDefault: false, + isEnabled: true, + isHidden: false, + isRelationship: true, + isRequired: false, + optionLabel: 'Taxon Citations', + tableName: 'TaxonCitation', + }, }, selectLabel: localized('Taxon'), tableName: 'Taxon', From 7d7cdd001bf5ce0efd706c50b117ea2ec0fb323c Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 13 Feb 2025 17:40:43 -0500 Subject: [PATCH 04/43] Remove param_count --- specifyweb/stored_queries/query_construct.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index cb9ccc9806d..0c73d81442a 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -18,11 +18,10 @@ def _safe_filter(query): return query.first() raise Exception(f"Got more than one matching: {list(query)}") -class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count param_count internal_filters')): +class QueryConstruct(namedtuple('QueryConstruct', 'collection objectformatter query join_cache tree_rank_count internal_filters')): def __new__(cls, *args, **kwargs): kwargs['join_cache'] = dict() - kwargs['param_count'] = 0 # TODO: Use tree_rank_count to implement cases where formatter of taxon is defined with fields from the parent. # In that case, the cycle will end (unlike other cyclical cases). kwargs['tree_rank_count'] = 0 @@ -72,15 +71,7 @@ def handle_tree_field(self, node, table, tree_rank, next_join_path, current_fiel assert len(treedefs_with_ranks) >= 1, "Didn't find the tree rank across any tree" - treedefitem_params = [] - for _, rank_id in treedefs_with_ranks: - treedefitem_param = sql.bindparam( - 'tdi_%s' % query.param_count, - value=rank_id - ) - treedefitem_params.append(treedefitem_param) - param_count = self.param_count + 1 - query = query._replace(param_count=param_count) + treedefitem_params = [treedefitem_id for (_, treedefitem_id) in treedefs_with_ranks] def make_tree_field_spec(tree_node): return current_field_spec._replace( From b3893724b597ac4ad50aa1e63a94173b10d6dd98 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Fri, 14 Feb 2025 11:14:10 -0500 Subject: [PATCH 05/43] Display tree name in query error --- .../js_src/lib/components/BatchEdit/index.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index a1db4ba4359..6151d9fcef6 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -18,6 +18,7 @@ import type { SerializedResource, } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { idFromUrl } from '../DataModel/resource'; import { schema } from '../DataModel/schema'; import { serializeResource } from '../DataModel/serializers'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; @@ -25,6 +26,7 @@ import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { SpQuery, Tables } from '../DataModel/types'; import { + getTreeDefinitions, isTreeTable, strictGetTreeDefinitionItems, treeRanksPromise, @@ -236,24 +238,30 @@ function findMissingRanks( const highestRank = currentRanksSorted[0]; - return allTreeDefItems.flatMap(({ rankId, name }) => + return allTreeDefItems.flatMap(({ treeDef, rankId, name }) => rankId < highestRank.specifyRank.rankId ? [] : filterArray( - requiredTreeFields.map((requiredField) => - currentTreeRanks.some( + requiredTreeFields.map((requiredField) => { + const treeDefinition = getTreeDefinitions( + treeTable.name as 'Geography', + idFromUrl(treeDef) + ); + const treeDefinitionName = treeDefinition[0].definition.name; + + return currentTreeRanks.some( (rank) => rank.specifyRank.name === name && rank.field !== undefined && requiredField === rank.field.name ) ? undefined - : `${name} ${ + : `${treeDefinitionName}: ${name} - ${ defined( strictGetTable(treeTable.name).getField(requiredField) ).label - }` - ) + }`; + }) ) ); } From aa81431a16b56f676319fd0db822a5310cbfe141 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 20 Feb 2025 12:24:13 -0500 Subject: [PATCH 06/43] Update TreeRankQuery to fix implicit ORs - see: https://github.com/specify/specify7/pull/6196#issuecomment-2669749739 --- specifyweb/stored_queries/queryfieldspec.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 933d274c400..93ce8b6107f 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -106,7 +106,16 @@ def make_stringid(fs, table_list): class TreeRankQuery(Relationship): # FUTURE: used to remember what the previous value was. Useless after 6 retires original_field: str - pass + + def __hash__(self): + return hash((TreeRankQuery, self.relatedModelName, self.name)) + + def __eq__(self, value): + return ( + isinstance(value, TreeRankQuery) + and value.name == self.name + and value.relatedModelName == self.relatedModelName + ) QueryNode = Union[Field, Relationship, TreeRankQuery] From 52e312fe80023278d09f0aa305ef3cbd383fb6cd Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 24 Feb 2025 13:57:08 -0500 Subject: [PATCH 07/43] Allow removing last row in Batch Edit --- .../lib/components/WorkBench/WbSpreadsheet.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx index f14bd6d1090..d9d57c0dabf 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx @@ -143,11 +143,14 @@ function WbSpreadsheetComponent({ if (isReadOnly) return true; // Or if called on the last row const selectedRegions = getSelectedRegions(hot); - return ( - selectedRegions.length === 1 && - selectedRegions[0].startRow === data.length - 1 && - selectedRegions[0].startRow === selectedRegions[0].endRow - ); + // Allow removing last row in Batch Edit since rows cannot be added in Batch Edit + const disableRemoveLastRow = dataset.isupdate + ? false + : selectedRegions[0].startRow === data.length - 1 && + selectedRegions[0].startRow === + selectedRegions[0].endRow; + + return selectedRegions.length === 1 && disableRemoveLastRow; }, }, disambiguate: { From 444eb32b9f50dcf36c9e57432120376c86442890 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 25 Feb 2025 14:56:42 -0500 Subject: [PATCH 08/43] Make a missing rank info dialog which proceeds to dataset creation --- .../js_src/lib/components/BatchEdit/index.tsx | 84 ++++++++++++++----- .../js_src/lib/localization/batchEdit.ts | 6 +- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 6151d9fcef6..084580ad947 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; +import { LocalizedString } from 'typesafe-i18n'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; @@ -78,7 +80,10 @@ export function BatchEditFromQuery({ limit: userPreferences.get('batchEdit', 'query', 'limit'), }), }); + const [errors, setErrors] = React.useState(undefined); + const [missingRanks, setMissingRanks] = React.useState(); + const [datasetName, setDatasetName] = React.useState(); const loading = React.useContext(LoadingContext); const queryFieldSpecs = React.useMemo( @@ -93,6 +98,17 @@ export function BatchEditFromQuery({ [fields] ); + const handleCreateDataset = async (newName: string) => { + return uniquifyDataSetName(newName, undefined, 'batchEdit').then( + async (name) => + post(name).then(({ data }) => { + setDatasetName(undefined); + setMissingRanks(undefined); + navigate(`/specify/workbench/${data.id}`); + }) + ); + }; + return ( <> filters.some((filter) => filter(fieldSpec)) ); - const hasErrors = - Object.values(missingRanks).some((ranks) => ranks.length > 0) || - invalidFields.length > 0; + const hasErrors = invalidFields.length > 0; if (hasErrors) { setErrors({ - missingRanks, invalidFields: invalidFields.map(queryFieldSpecHeader), }); return; @@ -123,12 +136,16 @@ export function BatchEditFromQuery({ queryName: query.get('name'), datePart: new Date().toDateString(), }); - return uniquifyDataSetName(newName, undefined, 'batchEdit').then( - async (name) => - post(name).then(({ data }) => - navigate(`/specify/workbench/${data.id}`) - ) + const hasMissingRanks = Object.values(missingRanks).some( + (ranks) => ranks.length > 0 ); + if (hasMissingRanks) { + setMissingRanks(missingRanks); + setDatasetName(newName); + return; + } + + return handleCreateDataset(newName); }) ); }} @@ -138,18 +155,25 @@ export function BatchEditFromQuery({ {errors !== undefined && ( setErrors(undefined)} /> )} + {missingRanks !== undefined && datasetName !== undefined ? ( + handleCreateDataset(datasetName)} + /> + ) : undefined} ); } type QueryError = { - readonly missingRanks: { - // Query can contain relationship to multiple trees - readonly [KEY in AnyTree['tableName']]: RA; - }; readonly invalidFields: RA; }; +type MissingRanks = { + // Query can contain relationship to multiple trees + readonly [KEY in AnyTree['tableName']]: RA; +}; + function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { const joinPath = queryFieldSpec.joinPath; if (joinPath.length <= 1) return false; @@ -180,9 +204,7 @@ const getTreeDefFromName = ( ) ); -function findAllMissing( - queryFieldSpecs: RA -): QueryError['missingRanks'] { +function findAllMissing(queryFieldSpecs: RA): MissingRanks { const treeFieldSpecs = group( filterArray( queryFieldSpecs.map((fieldSpec) => @@ -281,7 +303,25 @@ function ErrorsDialog({ onClose={handleClose} > - + + ); +} + +function MissingRanksDialog({ + missingRanks, + onClose: handleClose, +}: { + readonly missingRanks: MissingRanks; + readonly onClose: () => void; +}): JSX.Element { + return ( + + ); } @@ -305,17 +345,19 @@ function ShowInvalidFields({ } function ShowMissingRanks({ - error, + missingRanks, }: { - readonly error: QueryError['missingRanks']; + readonly missingRanks: MissingRanks; }) { - const hasMissing = Object.values(error).some((rank) => rank.length > 0); + const hasMissing = Object.values(missingRanks).some( + (rank) => rank.length > 0 + ); return hasMissing ? (

{batchEditText.addTreeRank()}

- {Object.entries(error).map(([treeTable, ranks]) => ( + {Object.entries(missingRanks).map(([treeTable, ranks]) => (
diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 6242a395d8a..bd9535b771d 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -18,7 +18,8 @@ export const batchEditText = createDictionary({ 'Field not supported for batch edit. Either remove the field, or make it hidden.', }, addTreeRank: { - 'en-us': 'Please add the following missing rank to the query', + 'en-us': + 'The following ranks will be added to the query to enable batch editing', }, datasetName: { 'en-us': '{queryName:string} {datePart:string}', @@ -26,6 +27,9 @@ export const batchEditText = createDictionary({ errorInQuery: { 'en-us': 'Following errors were found in the query', }, + missingRanksInQuery: { + 'en-us': 'Query requires additional ranks for batch editing', + }, createUpdateDataSetInstructions: { 'en-us': 'Use the query builder to make a new batch edit dataset', }, From 65f7d214ecb9d215b811c270e4c5bc8f84b5c48c Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 25 Feb 2025 16:03:57 -0500 Subject: [PATCH 09/43] Augment tree queries with missing ranks for batch edit --- specifyweb/specify/func.py | 4 + specifyweb/specify/tree_views.py | 22 +- specifyweb/stored_queries/batch_edit.py | 165 +++++++++----- .../batch_edit_query_rewrites.py | 208 ++++++++++++++++++ specifyweb/stored_queries/query_construct.py | 10 +- specifyweb/stored_queries/queryfieldspec.py | 100 ++++++--- specifyweb/workbench/upload/treerecord.py | 18 +- 7 files changed, 423 insertions(+), 104 deletions(-) create mode 100644 specifyweb/stored_queries/batch_edit_query_rewrites.py diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 4fd31f947f9..7c60e554808 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -49,6 +49,10 @@ def first(source: List[Tuple[I, O]]) -> List[I]: @staticmethod def second(source: List[Tuple[I, O]]) -> List[O]: return [second for (_, second) in source] + + @staticmethod + def filter_list(source: List[Optional[I]])->List[I]: + return [item for item in source if item is not None] class CustomRepr: def __init__(self, func, new_repr): diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 9264849f463..407054562c0 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -1,6 +1,6 @@ from functools import wraps from django import http -from typing import Literal, Tuple +from typing import Literal, Tuple, TypedDict, Any, Dict, List from django.db import connection, transaction from django.db.models import F, Q from django.http import HttpResponse @@ -511,11 +511,21 @@ def tree_rank_item_count(request, tree, rankid): @login_maybe_required @require_GET def all_tree_information(request): + result = get_all_tree_information(request.specify_collection, request.specify_user.id) + return HttpResponse(toJson(result), content_type="application/json") + +class TREE_INFORMATION(TypedDict): + # TODO: Stricten all this. + definition: Dict[Any, Any] + ranks: List[Dict[Any, Any]] + +# This is done to make tree fetching easier. +def get_all_tree_information(collection, user_id) -> Dict[str, List[TREE_INFORMATION]]: def has_tree_read_permission(tree: TREE_TABLE) -> bool: return has_table_permission( - request.specify_collection.id, request.specify_user.id, tree, 'read') + collection.id, user_id, tree, 'read') - is_paleo_or_geo_discipline = request.specify_collection.discipline.is_paleo_geo() + is_paleo_or_geo_discipline = collection.discipline.is_paleo_geo() accessible_trees = tuple(filter( has_tree_read_permission, ALL_TREES if is_paleo_or_geo_discipline else COMMON_TREES)) @@ -526,7 +536,7 @@ def has_tree_read_permission(tree: TREE_TABLE) -> bool: result[tree] = [] treedef_model = getattr(spmodels, f'{tree.lower().capitalize()}treedef') - tree_defs = treedef_model.objects.filter(get_search_filters(request.specify_collection, tree)).distinct() + tree_defs = treedef_model.objects.filter(get_search_filters(collection, tree)).distinct() for definition in tree_defs: ranks = definition.treedefitems.order_by('rankid') result[tree].append({ @@ -534,7 +544,7 @@ def has_tree_read_permission(tree: TREE_TABLE) -> bool: 'ranks': [obj_to_data(rank) for rank in ranks] }) - return HttpResponse(toJson(result), content_type='application/json') + return result class TaxonMutationPT(PermissionTarget): resource = "/tree/edit/taxon" @@ -597,4 +607,4 @@ def perm_target(tree): 'geologictimeperiod': GeologictimeperiodMutationPT, 'lithostrat': LithostratMutationPT, 'tectonicunit':TectonicunitMutationPT - }[tree] + }[tree] \ No newline at end of file diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 0a5fe27d8d5..bba0e98a60c 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -22,6 +22,7 @@ from specifyweb.specify.models import datamodel from specifyweb.specify.load_datamodel import Field, Relationship, Table from specifyweb.specify.datamodel import is_tree_table +from specifyweb.specify.tree_views import get_all_tree_information, TREE_INFORMATION from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import ( @@ -31,7 +32,7 @@ ) from specifyweb.workbench.models import Spdataset from specifyweb.workbench.permissions import BatchEditDataSetPT -from specifyweb.workbench.upload.treerecord import TreeRecord +from specifyweb.workbench.upload.treerecord import TreeRecord, TreeRankRecord, RANK_KEY_DELIMITER from specifyweb.workbench.upload.upload_plan_schema import parse_column_options from specifyweb.workbench.upload.upload_table import UploadTable from specifyweb.workbench.upload.uploadable import NULL_RECORD, Uploadable @@ -53,14 +54,15 @@ # - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). # - seemed complicated to merge upload plan from the frontend # - need to place id markers at correct level, so need to follow upload plan anyways. +# REFACTOR: Break this file into smaller pieaces # TODO: Play-around with localizing -NULL_RECORD_DESCRIPTION = "(Not included in the query results)" +BATCH_EDIT_NULL_RECORD_DESCRIPTION = "(Not included in the query results)" # TODO: add backend support for making system tables readonly -READONLY_TABLES = [*CONCRETE_HIERARCHY] +BATCH_EDIT_READONLY_TABLES = [*CONCRETE_HIERARCHY] -SHARED_READONLY_FIELDS = [ +BATCH_EDIT_SHARED_READONLY_FIELDS = [ "timestampcreated", "timestampmodified", "version", @@ -68,25 +70,27 @@ "highestchildnodenumber", "rankid", "fullname", - "age" + "age", ] -SHARED_READONLY_RELATIONSHIPS = ["createdbyagent", "modifiedbyagent"] +BATCH_EDIT_SHARED_READONLY_RELATIONSHIPS = ["createdbyagent", "modifiedbyagent"] + +BATCH_EDIT_REQUIRED_TREE_FIELDS = ["name"] def get_readonly_fields(table: Table): - fields = [*SHARED_READONLY_FIELDS, table.idFieldName.lower()] + fields = [*BATCH_EDIT_SHARED_READONLY_FIELDS, table.idFieldName.lower()] relationships = [ rel.name for rel in table.relationships - if rel.relatedModelName.lower() in READONLY_TABLES + if rel.relatedModelName.lower() in BATCH_EDIT_READONLY_TABLES ] if table.name.lower() == "determination": relationships = ["preferredtaxon"] elif is_tree_table(table): relationships = ["definitionitem"] - return fields, [*SHARED_READONLY_RELATIONSHIPS, *relationships] + return fields, [*BATCH_EDIT_SHARED_READONLY_RELATIONSHIPS, *relationships] FLOAT_FIELDS = ["java.lang.Float", "java.lang.Double", "java.math.BigDecimal"] @@ -179,7 +183,7 @@ def _query_field(field_spec: QueryFieldSpec, sort_type: int): display=True, format_name=None, sort_type=sort_type, - strict=False + strict=False, ) def _index( @@ -244,6 +248,12 @@ def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: if len(join_path) < 2: return False return isinstance(join_path[-2], TreeRankQuery) + +def get_tree_rank_record(key) -> TreeRankRecord: + from specifyweb.workbench.upload.treerecord import RANK_KEY_DELIMITER + + tree_name, rank_name, tree_def_id = tuple(key.split(RANK_KEY_DELIMITER)) + return TreeRankRecord(RANK_KEY_DELIMITER.join([tree_name, rank_name]), int(tree_def_id)) # These constants are purely for memory optimization, no code depends and/or cares if this is constant. @@ -261,6 +271,7 @@ class RowPlanMap(NamedTuple): to_one: Dict[str, "RowPlanMap"] = {} to_many: Dict[str, "RowPlanMap"] = {} is_naive: bool = True + tree_rank: Optional[TreeRankQuery] = None @staticmethod def _merge( @@ -282,12 +293,16 @@ def merge(self: "RowPlanMap", other: "RowPlanMap") -> "RowPlanMap": # That is, we'll currently incorrectly disallow making new ones. Fine for now. to_one = reduce(RowPlanMap._merge, other.to_one.items(), self.to_one) to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) + assert not ( + (self.tree_rank is None) ^ (other.tree_rank is None) + ), "Trying to merge inconsistent rowplanmaps" return RowPlanMap( batch_edit_pack, new_columns, to_one, to_many, is_naive=is_self_naive, + tree_rank=self.tree_rank, ) @staticmethod @@ -406,35 +421,36 @@ def _recur_row_plan( original_field, ) - boiler = RowPlanMap(columns=[], batch_edit_pack=batch_edit_pack) - - def _augment_is_naive(rel_type: Union[Literal["to_one"], Literal["to_many"]]): + remaining_map = remaining_map._replace(tree_rank=node if isinstance(node, TreeRankQuery) else None) - rest_plan = {rel_name: remaining_map} - if rel_type == "to_one": - # Propagate is_naive up - return boiler._replace( - is_naive=remaining_map.is_naive, to_one=rest_plan - ) + boiler = RowPlanMap( + columns=[], + batch_edit_pack=batch_edit_pack, + ) - # bc the user eperience guys want to be able to make new dets/preps one hop away - # but, we can't allow it for ordernumber when filtering. pretty annoying. - # and definitely not naive for any tree, well, technically it is possible, but for user's sake. - is_naive = not is_tree_table(next_table) and ( - ( - len(running_path) == 0 - and (remaining_map.batch_edit_pack.order.field is None) - ) - or remaining_map.is_naive - ) + rest_plan = {rel_name: remaining_map} + if rel_type == "to_one": + # Propagate is_naive up return boiler._replace( - to_many={ - # to force-naiveness - rel_name: remaining_map._replace(is_naive=is_naive) - } + is_naive=remaining_map.is_naive, to_one=rest_plan ) - return _augment_is_naive(rel_type) + # bc the user eperience guys want to be able to make new dets/preps one hop away + # but, we can't allow it for ordernumber when filtering. pretty annoying. + # and definitely not naive for any tree, well, technically it is possible, but for user's sake. + is_naive = not is_tree_table(next_table) and ( + ( + len(running_path) == 0 + and (remaining_map.batch_edit_pack.order.field is None) + ) + or remaining_map.is_naive + ) + return boiler._replace( + to_many={ + # to force-naiveness + rel_name: remaining_map._replace(is_naive=is_naive) + } + ) # generates multiple row plan maps, and merges them into one # this doesn't index the row plan, bc that is complicated. @@ -494,7 +510,9 @@ def nullify(self, parent_is_phantom=False) -> "RowPlanCanonical": # since is_naive is set, is_phantom = parent_is_phantom or not self.is_naive columns = [ - pack._replace(value=NULL_RECORD_DESCRIPTION if is_phantom else None) + pack._replace( + value=BATCH_EDIT_NULL_RECORD_DESCRIPTION if is_phantom else None + ) for pack in self.columns ] to_ones = { @@ -536,6 +554,15 @@ def to_many_planner(self) -> "RowPlanMap": to_many=to_many, ) + def rewrite( + self, table: Table, all_tree_info: TREE_INFORMATION, running_path=[] + ) -> "RowPlanMap": + from .batch_edit_query_rewrites import _batch_edit_rewrite # ugh, fix this + + # NOTE: This is written in a very generic way, and makes future rewrites also not too hard. + # However, tree rank rewrites was probably the hardest that needed to be done. + return _batch_edit_rewrite(self, table, all_tree_info, running_path) + # the main data-structure which stores the data # RowPlanMap is just a map, this stores actual data (to many is a dict of list, rather than just a dict) @@ -820,7 +847,7 @@ def _flatten(_: str, _self: "RowPlanCanonical"): def to_upload_plan( self, base_table: Table, - localization_dump: Dict[str, str], + localization_dump: Dict[str, Dict[str, str]], query_fields: List[QueryField], fields_added: Dict[str, int], get_column_id: Callable[[str], int], @@ -841,10 +868,15 @@ def _lookup_in_fields(_id: Optional[int], readonly_fields: List[str]): field = query_fields[ _id - 1 ] # Need to go off by 1, bc we added 1 to account for id fields + table_name, field_name = _get_table_and_field(field) + field_labels = localization_dump.get(table_name, {}) + # It could happen that the field we saw doesn't exist. + # Plus, the default options get chosen in the cases of + if field_name not in field_labels or field.fieldspec.contains_tree_rank(): + localized_label = naive_field_format(field.fieldspec) + else: + localized_label = field_labels[field_name] string_id = field.fieldspec.to_stringid() - localized_label = localization_dump.get( - string_id, naive_field_format(field.fieldspec) - ) fields_added[localized_label] = fields_added.get(localized_label, 0) + 1 _count = fields_added[localized_label] if _count > 1: @@ -856,7 +888,7 @@ def _lookup_in_fields(_id: Optional[int], readonly_fields: List[str]): or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != "Full Date") or fieldspec.get_field().name.lower() in readonly_fields - or fieldspec.table.name.lower() in READONLY_TABLES + or fieldspec.table.name.lower() in BATCH_EDIT_READONLY_TABLES ) id_in_original_fields = get_column_id(string_id) return ( @@ -930,7 +962,7 @@ def _relationship_is_editable(name, value): upload_plan: Uploadable = TreeRecord( name=base_table.django_name, ranks={ - key: upload_table.wbcols # type: ignore + get_tree_rank_record(key): upload_table.wbcols # type: ignore for (key, upload_table) in to_one_upload_tables.items() }, ) @@ -955,13 +987,16 @@ def _relationship_is_editable(name, value): # Using this as a last resort to show fields, for unit tests def naive_field_format(fieldspec: QueryFieldSpec): field = fieldspec.get_field() + tree_rank = fieldspec.get_first_tree_rank() + prefix = f"{tree_rank[1].treedef_name} - {tree_rank[1].name} - " if tree_rank else "" if field is None: - return f"{fieldspec.table.name} (formatted)" + return f"{prefix}{fieldspec.table.name} (formatted)" if field.is_relationship: - return f"{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" - return f"{fieldspec.table.name} {field.name}" + return f"{prefix}{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" + return f"{prefix}{fieldspec.table.name} {field.name}" +# @transaction.atomic <--- we DONT do this because the query logic could take up possibly multiple minutes def run_batch_edit(collection, user, spquery, agent): props = BatchEditProps( collection=collection, @@ -972,7 +1007,7 @@ def run_batch_edit(collection, user, spquery, agent): recordsetid=spquery.get("recordsetid", None), fields=fields_from_json(spquery["fields"]), session_maker=models.session_context, - omit_relationships=True + omit_relationships=True, ) (headers, rows, packs, json_upload_plan, visual_order) = run_batch_edit_query(props) mapped_raws = [ @@ -992,7 +1027,6 @@ def run_batch_edit(collection, user, spquery, agent): ) -# @transaction.atomic <--- we DONT do this because the query logic could take up possibly multiple minutes class BatchEditProps(TypedDict): collection: Any user: Any @@ -1004,6 +1038,10 @@ class BatchEditProps(TypedDict): fields: List[QueryField] omit_relationships: Optional[bool] +def _get_table_and_field(field: QueryField): + table_name = field.fieldspec.table.name + field_name = None if field.fieldspec.get_field() is None else field.fieldspec.get_field().name + return (table_name, field_name) def run_batch_edit_query(props: BatchEditProps): @@ -1021,17 +1059,19 @@ def run_batch_edit_query(props: BatchEditProps): len(visible_fields) == len(captions) ), "Got misaligned captions!" - localization_dump: Dict[str, str] = ( - { - # we cannot use numbers since they can very off - field.fieldspec.to_stringid(): caption - for field, caption in zip(visible_fields, captions) - } - if captions is not None - else {} - ) + localization_dump = {} + if captions: + for (field, caption) in zip(visible_fields, captions): + table_name, field_name = _get_table_and_field(field) + field_labels = localization_dump.get(table_name, {}) + field_labels[field_name] = caption + localization_dump[table_name] = field_labels - row_plan = RowPlanMap.get_row_plan(visible_fields) + naive_row_plan = RowPlanMap.get_row_plan(visible_fields) + all_tree_info = get_all_tree_information(props["collection"], props["user"].id) + base_table = datamodel.get_table_by_id_strict(tableid, strict=True) + running_path = [base_table.name] + row_plan = naive_row_plan.rewrite(base_table, all_tree_info, running_path) indexed, query_fields = row_plan.index_plan() # we don't really care about these fields, since we'have already done the numbering (and it won't break with @@ -1089,12 +1129,15 @@ def run_batch_edit_query(props: BatchEditProps): ), "Made irregular rows somewhere!" def _get_orig_column(string_id: str): - return next( + try: + return next( filter( lambda field: field[1].fieldspec.to_stringid() == string_id, enumerate(visible_fields), - ) - )[0] + ))[0] + except StopIteration: + # Put the other ones at the very last. + return len(visible_fields) # Consider optimizing when relationships are not-editable? May not benefit actually # This permission just gets enforced here @@ -1107,7 +1150,7 @@ def _get_orig_column(string_id: str): # The keys are lookups into original query field (not modified by us). Used to get ids in the original one. key_and_headers, upload_plan = extend_row.to_upload_plan( - datamodel.get_table_by_id_strict(tableid, strict=True), + base_table, localization_dump, query_fields, {}, @@ -1169,4 +1212,4 @@ def make_dataset( # Create the backer. ds.save() - return (ds_id, ds_name) + return (ds_id, ds_name) \ No newline at end of file diff --git a/specifyweb/stored_queries/batch_edit_query_rewrites.py b/specifyweb/stored_queries/batch_edit_query_rewrites.py new file mode 100644 index 00000000000..038f762b622 --- /dev/null +++ b/specifyweb/stored_queries/batch_edit_query_rewrites.py @@ -0,0 +1,208 @@ +from functools import reduce +from typing import Any, Dict, List, Set, Tuple +from specifyweb.specify.models import datamodel +from specifyweb.specify.func import Func +from specifyweb.specify.load_datamodel import Table +from specifyweb.specify.tree_views import TREE_INFORMATION +from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec, TreeRankQuery +from .batch_edit import BatchEditFieldPack, BatchEditPack, RowPlanMap + +BATCH_EDIT_REQUIRED_TREE_FIELDS: Set[str] = {"name"} + + +def _track_observed_ranks( + table_name, + running_path, + tree_def, + all_current_ranks: Dict[str, Dict[Any, Any]], + accum: Tuple[List[int], List[Tuple[str, RowPlanMap]]], + _current: Tuple[str, RowPlanMap], +): + # 1. if tree rank itself is None (non tree field), nothing to do. + # 2. if the tree rank that's in the query is not in the current ranks, ignore them. + # 3. if the tree rank already has a specialized tree. There is no current way in which this can naturally happen but this does avoid + # a future bug when multiple treedef queries are supported. + relname, current = _current + if ( + current.tree_rank is None + or (current.tree_rank.relatedModelName != table_name) + or current.tree_rank.treedef_id is not None + or (current.tree_rank.name not in all_current_ranks) + ): + return accum + + current_rank = all_current_ranks[current.tree_rank.name] + # Here, we also modify the columns to adjust the missing field stuff. + current_fields = Func.filter_list( + [ + None if column.field is None else column.field.fieldspec.get_field() + for column in current.columns + ] + ) + + # if the current_field is not found, insert them into the query with fields. + naive_field_spec = QueryFieldSpec.from_path(running_path) + adjusted_field_spec = lambda field_name: naive_field_spec._replace( + join_path=( + *naive_field_spec.join_path, + current.tree_rank, + naive_field_spec.table.get_field_strict(field_name), + ) + ) + # Now, we need to run the adjuster over all the fields that are required but did not appear + required_missing = BATCH_EDIT_REQUIRED_TREE_FIELDS - set( + field.name for field in current_fields + ) + extra_columns = [ + BatchEditFieldPack( + field=BatchEditPack._query_field(adjusted_field_spec(field_name), 0) + ) + for field_name in required_missing + ] + + new_columns = [*current.columns, *extra_columns] + new_tree_rank_query = TreeRankQuery.create( + current.tree_rank.name, current.tree_rank.relatedModelName, tree_def["id"],tree_def["name"] + ) + + new_columns = [] + for column in [*current.columns, *extra_columns]: + column_field = column.field + new_field_spec = column_field.fieldspec._replace( + join_path=tuple( + [ + new_tree_rank_query if isinstance(node, TreeRankQuery) else node + for node in column_field.fieldspec.join_path + ] + ) + ) + new_columns.append( + column._replace(field=column_field._replace(fieldspec=new_field_spec)) + ) + + return [*accum[0], current_rank["rankid"]], [ + *accum[1], + # Making a copy here is important. + (relname, current._replace(columns=new_columns, tree_rank=new_tree_rank_query)), + ] + + +def _rewrite_multiple_trees( + running_path, + current: Dict[str, RowPlanMap], + all_tree_info: Dict[str, List[TREE_INFORMATION]], +) -> Dict[str, RowPlanMap]: + # We now rewrite the query for multiple trees. We need to do this because we don't support querying a specific treedef. + # Multiple different iterations were went into this: + # 1. Trying it on frontend + # 2. Trying it on backend + # 2.a: Rewriting directly on fields + # This place is currently more simpler than other places tried. + + new_rels: List[Tuple[str, RowPlanMap]] = [ + (key, value) for (key, value) in current.items() if value.tree_rank is None + ] + + # TODO: Check if the first loop is needed at all? Just do alltree_info[table.name] and go from there? + for table_name, multiple_tree_info in all_tree_info.items(): + for single_tree_info in multiple_tree_info: + augmented_tree_info = { + rank["name"]: rank for rank in single_tree_info["ranks"] + } + ranks_found, rels_created = reduce( + lambda p, c: _track_observed_ranks( + table_name, running_path, single_tree_info['definition'], augmented_tree_info, p, c + ), + current.items(), + ([], []), + ) + # This means that no rank was selected for this tree, so we completely skip this (no point in adding multiples) + if len(ranks_found) == 0: + continue + # We now add the new ranks that were initially missing. + min_rank_id = ranks_found[0] if len(ranks_found) == 1 else min(*ranks_found) + ranks_to_add = [ + rank["name"] + for rank in single_tree_info["ranks"] + if rank["rankid"] > min_rank_id + and rank["rankid"] not in ranks_found + ] + fieldspec = QueryFieldSpec.from_path(running_path) + # To make things "simpler", we just run the reducer again. + template_plans = {} + for rank in ranks_to_add: + tree_rank_query = TreeRankQuery.create(rank, table_name) + adjusted = fieldspec._replace( + join_path=(*fieldspec.join_path, tree_rank_query) + ) + template_plans = { + **template_plans, + rank: RowPlanMap( + batch_edit_pack=BatchEditPack.from_field_spec(adjusted), + tree_rank = tree_rank_query + ) + } + final_ranks_created, final_rels_created = reduce( + lambda p, c: _track_observed_ranks( + table_name, running_path, single_tree_info['definition'], augmented_tree_info, p, c + ), + template_plans.items(), + ([], []), + ) + assert len(final_ranks_created) == len(ranks_to_add) + + new_rels = [ + *new_rels, + # NOTE: The order between finals_rels_created and rels_created does not matter + *final_rels_created, + *rels_created, + ] + + # Now, we'have done the iteration over all the possible treees and have made the corresponding tree query ranks in the columns + # just scoped to a specific query. The only thing remaining is adjusting the name of the relationship being used. + # Note that new_rels is a list on purpose. Now, it contains all the relationships corrected, but it contain duplicated first key. + # We now make them deduplicated, by using the unique name that treerankquery makes. + new_rels = [ + ( + rel if value.tree_rank is None else value.tree_rank.get_workbench_name(), + value, + ) + for rel, value in new_rels + ] + # Duplicates are not possible here. + assert len(set(Func.first(new_rels))) == len( + new_rels + ), f"Duplicates created: {new_rels}" + + # It is not this function's responsibility to perform rewrites on next plans. + return {key: value for (key, value) in new_rels} + +def _safe_table(key: str, table: Table): + field = table.get_field(key) + if field is None: + return table + return datamodel.get_table_strict(field.relatedModelName) + +def _batch_edit_rewrite( + self: RowPlanMap, table: Table, all_tree_info: TREE_INFORMATION, running_path=[] +) -> RowPlanMap: + + to_ones = { + key: value.rewrite( + _safe_table(key, table), + all_tree_info, + [*running_path, key], + ) + for (key, value) in self.to_one.items() + } + to_many = { + key: value.rewrite( + _safe_table(key, table), + all_tree_info, + [*running_path, key], + ) + for (key, value) in self.to_many.items() + } + to_ones = _rewrite_multiple_trees(running_path, to_ones, all_tree_info) + to_many = _rewrite_multiple_trees(running_path, to_many, all_tree_info) + return self._replace(to_one=to_ones, to_many=to_many) \ No newline at end of file diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index 0c73d81442a..6793b0ef0fd 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -28,13 +28,13 @@ def __new__(cls, *args, **kwargs): kwargs['internal_filters'] = [] return super(QueryConstruct, cls).__new__(cls, *args, **kwargs) - def handle_tree_field(self, node, table, tree_rank, next_join_path, current_field_spec: QueryFieldSpec): + def handle_tree_field(self, node, table, tree_rank: TreeRankQuery, next_join_path, current_field_spec: QueryFieldSpec): query = self if query.collection is None: raise AssertionError( # Not sure it makes sense to query across collections f"No Collection found in Query for {table}", {"table" : table, "localizationKey" : "noCollectionInQuery"}) - logger.info('handling treefield %s rank: %s field: %s', table, tree_rank, next_join_path) + logger.info('handling treefield %s rank: %s field: %s', table, tree_rank.name, next_join_path) treedefitem_column = table.name + 'TreeDefItemID' treedef_column = table.name + 'TreeDefID' @@ -65,8 +65,10 @@ def handle_tree_field(self, node, table, tree_rank, next_join_path, current_fiel # TODO: optimize out the ranks that appear? cache them treedefs_with_ranks: List[Tuple[int, int]] = [tup for tup in [ - (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank).values_list('id', flat=True))) + (treedef_id, _safe_filter(item_model.objects.filter(treedef_id=treedef_id, name=tree_rank.name).values_list('id', flat=True))) for treedef_id, _ in treedefs + # For constructing tree queries for batch edit + if (tree_rank.treedef_id is None or tree_rank.treedef_id == treedef_id) ] if tup[1] is not None] assert len(treedefs_with_ranks) >= 1, "Didn't find the tree rank across any tree" @@ -152,4 +154,4 @@ def proxy(self, *args, **kwargs): setattr(QueryConstruct, name, proxy) for name in 'filter join outerjoin add_columns reset_joinpoint group_by'.split(): - add_proxy_method(name) + add_proxy_method(name) \ No newline at end of file diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 93ce8b6107f..21f5b31914c 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -1,4 +1,3 @@ -from dataclasses import fields import logging import re from collections import namedtuple, deque @@ -9,7 +8,7 @@ from specifyweb.specify.load_datamodel import Field, Table from specifyweb.specify.models import datamodel from specifyweb.specify.uiformatters import get_uiformatter -# from specifyweb.specify.geo_time import query_co_in_time_range + from . import models from .query_ops import QueryOps from ..specify.load_datamodel import Table, Field, Relationship @@ -27,19 +26,20 @@ DATE_PART_RE = re.compile(r"(.*)((NumericDay)|(NumericMonth)|(NumericYear))$") # Pull out author or groupnumber field from taxon query fields. -TAXON_FIELD_RE = re.compile(r'(.*) ((Author)|(groupNumber))$') +TAXON_FIELD_RE = re.compile(r"(.*) ((Author)|(groupNumber))$") # Pull out geographyCode field from geography query fields. -GEOGRAPHY_FIELD_RE = re.compile(r'(.*) ((geographyCode))$') +GEOGRAPHY_FIELD_RE = re.compile(r"(.*) ((geographyCode))$") # Look to see if we are dealing with a tree node ID. -TREE_ID_FIELD_RE = re.compile(r'(.*) (ID)$') +TREE_ID_FIELD_RE = re.compile(r"(.*) (ID)$") # Precalculated fields that are not in the database. Map from table name to field name. PRECALCULATED_FIELDS = { - 'CollectionObject': 'age', + "CollectionObject": "age", } + def extract_date_part(fieldname): match = DATE_PART_RE.match(fieldname) if match: @@ -103,9 +103,14 @@ def make_stringid(fs, table_list): field_name += "Numeric" + fs.date_part return table_list, fs.table.name.lower(), field_name.strip() + class TreeRankQuery(Relationship): # FUTURE: used to remember what the previous value was. Useless after 6 retires original_field: str + # This is used to query a particular treedef. If this is none, all treedefs are searched, otherwise a specific treedef is searched. + treedef_id: Optional[int] + # Yeah this can be inferred from treedef_id but doing it this way avoids a database lookup because we already fetch it once. + treedef_name: Optional[str] def __hash__(self): return hash((TreeRankQuery, self.relatedModelName, self.name)) @@ -117,13 +122,33 @@ def __eq__(self, value): and value.relatedModelName == self.relatedModelName ) + @staticmethod + def create(name, table_name, treedef_id=None, treedef_name=None): + obj = TreeRankQuery( + name=name, + relatedModelName=table_name, + type="many-to-one", + column=datamodel.get_table_strict(table_name).idFieldName + ) + obj.treedef_id = treedef_id + obj.treedef_name = treedef_name + return obj + + def get_workbench_name(self): + from specifyweb.workbench.upload.treerecord import RANK_KEY_DELIMITER + # Treedef id included to make it easier to pass it to batch edit + return f"{self.treedef_name}{RANK_KEY_DELIMITER}{self.name}{RANK_KEY_DELIMITER}{self.treedef_id}" + QueryNode = Union[Field, Relationship, TreeRankQuery] FieldSpecJoinPath = Tuple[QueryNode] class QueryFieldSpec( - namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part tree_rank tree_field") + namedtuple( + "QueryFieldSpec", + "root_table root_sql_table join_path table date_part tree_rank tree_field", + ) ): root_table: Table root_sql_table: SQLTable @@ -162,7 +187,7 @@ def from_path(cls, path_in, add_id=False): "Full Date" if (join_path and join_path[-1].is_temporal()) else None ), tree_rank=None, - tree_field=None + tree_field=None, ) @classmethod @@ -195,11 +220,9 @@ def from_stringid(cls, stringid, is_relation): if field is None: # try finding tree tree_rank_name, field = find_tree_and_field(node, extracted_fieldname) if tree_rank_name: - tree_rank = TreeRankQuery( - name=tree_rank_name, - relatedModelName=node.name, - type="many-to-one", - column=node.idField.column + tree_rank = TreeRankQuery.create( + tree_rank_name, + node.name ) # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent join_path.append(tree_rank) @@ -218,7 +241,7 @@ def from_stringid(cls, stringid, is_relation): table=node, date_part=date_part, tree_rank=tree_rank_name, - tree_field=field + tree_field=field, ) logger.debug( @@ -233,8 +256,17 @@ def from_stringid(cls, stringid, is_relation): def __init__(self, *args, **kwargs): self.validate() + def get_first_tree_rank(self): + for node in enumerate(list(self.join_path)): + if isinstance(node[1], TreeRankQuery): + return node + return None + + def contains_tree_rank(self): + return self.get_first_tree_rank() is not None + def validate(self): - valid_date_parts = ('Full Date', 'Day', 'Month', 'Year', None) + valid_date_parts = ("Full Date", "Day", "Month", "Year", None) assert self.is_temporal() or self.date_part is None if self.date_part not in valid_date_parts: raise AssertionError( @@ -299,9 +331,21 @@ def is_specify_username_end(self): def needs_formatted(self): return len(self.join_path) == 0 or self.is_relationship() - - def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, negate=False, strict=False): - no_filter = op_num is None or (self.tree_rank is None and self.get_field() is None) + + def apply_filter( + self, + query, + orm_field, + field, + table, + value=None, + op_num=None, + negate=False, + strict=False, + ): + no_filter = op_num is None or ( + self.tree_rank is None and self.get_field() is None + ) if not no_filter: if isinstance(value, QueryFieldSpec): _, other_field, _ = value.add_to_query(query.reset_joinpoint()) @@ -316,7 +360,9 @@ def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, query_op = QueryOps(uiformatter) op = query_op.by_op_num(op_num) if query_op.is_precalculated(op_num): - f = op(orm_field, value, query, is_strict=strict) # Needed if using op_age_range_simple + f = op( + orm_field, value, query, is_strict=strict + ) # Needed if using op_age_range_simple # Handle modifying query from op_age_range # new_query = op(orm_field, value, query, is_strict=strict) # query = query._replace(query=new_query) @@ -338,7 +384,7 @@ def add_to_query( negate=False, formatter=None, formatauditobjs=False, - strict=False + strict=False, ): # print "############################################################################" # print "formatauditobjs " + str(formatauditobjs) @@ -347,7 +393,9 @@ def add_to_query( # print "is auditlog obj format field = " + str(self.is_auditlog_obj_format_field(formatauditobjs)) # print "############################################################################" query, orm_field, field, table = self.add_spec_to_query(query, formatter) - return self.apply_filter(query, orm_field, field, table, value, op_num, negate, strict=strict) + return self.apply_filter( + query, orm_field, field, table, value, op_num, negate, strict=strict + ) def add_spec_to_query( self, query, formatter=None, aggregator=None, cycle_detector=[] @@ -362,7 +410,7 @@ def add_spec_to_query( if self.is_relationship(): # will be formatting or aggregating related objects - if self.get_field().type in {'many-to-one', 'one-to-one'}: + if self.get_field().type in {"many-to-one", "one-to-one"}: query, orm_model, table, field = self.build_join(query, self.join_path) query, orm_field = query.objectformatter.objformat( query, orm_model, formatter, cycle_detector @@ -385,7 +433,7 @@ def add_spec_to_query( query, orm_field, field, table = query.handle_tree_field( orm_model, table, - field.name, + field, self.join_path[tree_rank_idx + 1 :], self, ) @@ -397,7 +445,9 @@ def add_spec_to_query( if table.name in PRECALCULATED_FIELDS: field_name = PRECALCULATED_FIELDS[table.name] # orm_field = getattr(orm_model, field_name) - orm_field = getattr(orm_model, orm_model._id) # Replace with recordId, future just remove column from results + orm_field = getattr( + orm_model, orm_model._id + ) # Replace with recordId, future just remove column from results else: raise @@ -411,4 +461,4 @@ def add_spec_to_query( if field.is_temporal() and self.date_part != "Full Date": orm_field = sql.extract(self.date_part, orm_field) - return query, orm_field, field, table + return query, orm_field, field, table \ No newline at end of file diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 5c1f8ba2825..9385c1d9f1e 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -4,8 +4,6 @@ from itertools import groupby import logging -from math import e -from os import error from typing import List, Dict, Any, Tuple, NamedTuple, Optional, Union, Set from django.db import transaction, IntegrityError @@ -54,6 +52,9 @@ logger = logging.getLogger(__name__) +# Rank keys in the upload plan have the format: ~> +RANK_KEY_DELIMITER = "~>" + class TreeRankCell(NamedTuple): treedef_id: int treedefitem_name: str @@ -147,7 +148,10 @@ def get_treedef_id( """ Get the treedef ID for the given rank name and tree. """ - + + if treedef_id is not None: + return treedef_id + # Fetch treedefitems based on filter keyword arguments def fetch_treedefitems(filter_kwargs): return tree_model.objects.filter(**filter_kwargs) @@ -165,9 +169,6 @@ def handle_multiple_items(treedefitems, tree, treedef_id): return filter_by_treedef_id(treedefitems, rank_name, tree, treedef_id) return treedefitems - if treedef_id is not None: - return treedef_id - if treedef_id is None and treedef_name is not None: treedef_id = get_treedef_model(tree).objects.get(name=treedef_name) @@ -192,7 +193,7 @@ def extract_treedef_name(rank_name: str) -> Tuple[str, Optional[str]]: """ Extract treedef_name from rank_name if it exists in the format 'treedef_name~>rank_name'. """ - parts = rank_name.split('~>', 1) + parts = rank_name.split(RANK_KEY_DELIMITER, 1) if len(parts) == 2: treedef_name = parts[0] rank_name = parts[1] @@ -272,6 +273,7 @@ def check_rank(self, tree: str) -> bool: def validate_rank(self, tree) -> None: TreeRank.create(self.rank_name, tree, self.treedef_id).validate_rank() + class TreeRecord(NamedTuple): name: str ranks: Dict[Union[str, TreeRankRecord], Dict[str, ColumnOptions]] @@ -293,7 +295,7 @@ def to_json(self) -> Dict: rank_key = rank.rank_name if isinstance(rank, TreeRankRecord) else rank treeNodeCols = {k: v.to_json() if hasattr(v, "to_json") else v for k, v in cols.items()} - if len(cols) == 1: + if len(cols) == 1 and not isinstance(rank, TreeRankRecord): result["ranks"][rank_key] = treeNodeCols["name"] else: rank_data = {"treeNodeCols": treeNodeCols} From 46fe3706abf771c9bd506cfc240ec7ccd940d2f8 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 25 Feb 2025 21:07:42 +0000 Subject: [PATCH 10/43] Lint code with ESLint and Prettier Triggered by 65f7d214ecb9d215b811c270e4c5bc8f84b5c48c on branch refs/heads/issue-6127 --- .../frontend/js_src/lib/components/BatchEdit/index.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 084580ad947..0b92cfafcfb 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { LocalizedString } from 'typesafe-i18n'; +import type { LocalizedString } from 'typesafe-i18n'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; @@ -98,8 +98,7 @@ export function BatchEditFromQuery({ [fields] ); - const handleCreateDataset = async (newName: string) => { - return uniquifyDataSetName(newName, undefined, 'batchEdit').then( + const handleCreateDataset = async (newName: string) => uniquifyDataSetName(newName, undefined, 'batchEdit').then( async (name) => post(name).then(({ data }) => { setDatasetName(undefined); @@ -107,7 +106,6 @@ export function BatchEditFromQuery({ navigate(`/specify/workbench/${data.id}`); }) ); - }; return ( <> @@ -158,7 +156,7 @@ export function BatchEditFromQuery({ {missingRanks !== undefined && datasetName !== undefined ? ( handleCreateDataset(datasetName)} + onClose={async () => handleCreateDataset(datasetName)} /> ) : undefined} From 59eb7ffba7a9871d2885f7794bac777eaecedb77 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 25 Feb 2025 16:43:01 -0500 Subject: [PATCH 11/43] Adjust apply_batch_edit_pack for multiple trees --- specifyweb/workbench/upload/treerecord.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 9385c1d9f1e..64bcab6eb52 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -1076,11 +1076,13 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: previous_parent_id = None for tdi in self.treedefitems[::-1]: - if tdi.name not in self.batch_edit_pack: + ref_key = f"{tdi.treedef.name}{RANK_KEY_DELIMITER}{tdi.name}{RANK_KEY_DELIMITER}{tdi.treedef.id}" + tree_rank_record = TreeRankRecord(tdi.name, tdi.treedef.id) + if ref_key not in self.batch_edit_pack: continue - columns = [pr.column for pr in self.parsedFields[tdi.name]] + columns = [pr.column for pr in self.parsedFields[tree_rank_record]] info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) - pack = self.batch_edit_pack[tdi.name] + pack = self.batch_edit_pack[ref_key] try: reference = safe_fetch( model, {"id": pack["id"]}, pack.get("version", None) From 79bdb27f5da173cfc95389cef2605b6c36bf90a7 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 25 Feb 2025 17:48:48 -0500 Subject: [PATCH 12/43] Add a discipline type in tests --- specifyweb/specify/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index d5e3d51ba89..e13f62ab01b 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -77,6 +77,7 @@ def setUp(self): geographytreedef=self.geographytreedef, division=self.division, datatype=self.datatype, + type='paleobotany' ) apply_default_uniqueness_rules(self.discipline) From 17201319612a1956075439726701f96b2eb022ba Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 3 Mar 2025 11:41:19 -0500 Subject: [PATCH 13/43] Un-enforce TreeRankRecord in upload plan --- specifyweb/workbench/upload/treerecord.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 64bcab6eb52..c79c25cc673 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -295,7 +295,7 @@ def to_json(self) -> Dict: rank_key = rank.rank_name if isinstance(rank, TreeRankRecord) else rank treeNodeCols = {k: v.to_json() if hasattr(v, "to_json") else v for k, v in cols.items()} - if len(cols) == 1 and not isinstance(rank, TreeRankRecord): + if len(cols) == 1: result["ranks"][rank_key] = treeNodeCols["name"] else: rank_data = {"treeNodeCols": treeNodeCols} From 4f161f560f4955d071030317ee1366e770c8c04f Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 3 Mar 2025 11:53:26 -0500 Subject: [PATCH 14/43] Add loading action to missing ranks dialog --- .../js_src/lib/components/BatchEdit/index.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 0b92cfafcfb..0c457c254a2 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -98,13 +98,13 @@ export function BatchEditFromQuery({ [fields] ); - const handleCreateDataset = async (newName: string) => uniquifyDataSetName(newName, undefined, 'batchEdit').then( - async (name) => - post(name).then(({ data }) => { - setDatasetName(undefined); - setMissingRanks(undefined); - navigate(`/specify/workbench/${data.id}`); - }) + const handleCreateDataset = async (newName: string) => + uniquifyDataSetName(newName, undefined, 'batchEdit').then(async (name) => + post(name).then(({ data }) => { + setDatasetName(undefined); + setMissingRanks(undefined); + navigate(`/specify/workbench/${data.id}`); + }) ); return ( @@ -156,7 +156,7 @@ export function BatchEditFromQuery({ {missingRanks !== undefined && datasetName !== undefined ? ( handleCreateDataset(datasetName)} + onClose={async () => loading(handleCreateDataset(datasetName))} /> ) : undefined} From 18305a647cd4422bd49f4084d0711c99c4ed116f Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 3 Mar 2025 13:20:22 -0500 Subject: [PATCH 15/43] Move table name to same line in missing fields dialog --- .../frontend/js_src/lib/components/BatchEdit/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 0c457c254a2..73c55f46534 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -357,8 +357,11 @@ function ShowMissingRanks({
{Object.entries(missingRanks).map(([treeTable, ranks]) => (
-
- +
+

{strictGetTable(treeTable).label}

From 78a6769089e87faef155b3d530b6234ac420b066 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 3 Mar 2025 14:40:16 -0500 Subject: [PATCH 16/43] Handle case when rank name has spaces --- specifyweb/stored_queries/queryfieldspec.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 21f5b31914c..56052ef95f9 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -78,11 +78,18 @@ def find_tree_and_field(table, fieldname: str): fieldname = fieldname.strip() if fieldname == "": return None, None - # NOTE: Assumes rank names have no spaces + tree_rank_and_field = fieldname.split(" ") mapping = make_tree_fieldnames(table) + + # BUG: Edge case when there's no field AND rank name has a space? if len(tree_rank_and_field) == 1: return tree_rank_and_field[0], mapping[""] + + # Handles case where rank name contains spaces + if len(tree_rank_and_field) > 2: + tree_rank_and_field = [" ".join(tree_rank_and_field[:-1]), tree_rank_and_field[-1]] + tree_rank, tree_field = tree_rank_and_field return tree_rank, mapping.get(tree_field, tree_field) From d65495f7cd84ec5ab9fba7a67ea90bb4746bfe79 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 3 Mar 2025 15:55:10 -0500 Subject: [PATCH 17/43] Add a close button to missing ranks dialog --- .../js_src/lib/components/BatchEdit/index.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 73c55f46534..81276a9a290 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -98,11 +98,15 @@ export function BatchEditFromQuery({ [fields] ); + const handleCloseDialog = () => { + setDatasetName(undefined); + setMissingRanks(undefined); + }; + const handleCreateDataset = async (newName: string) => uniquifyDataSetName(newName, undefined, 'batchEdit').then(async (name) => post(name).then(({ data }) => { - setDatasetName(undefined); - setMissingRanks(undefined); + handleCloseDialog(); navigate(`/specify/workbench/${data.id}`); }) ); @@ -156,7 +160,8 @@ export function BatchEditFromQuery({ {missingRanks !== undefined && datasetName !== undefined ? ( loading(handleCreateDataset(datasetName))} + onClose={handleCloseDialog} + onContinue={async () => loading(handleCreateDataset(datasetName))} /> ) : undefined} @@ -307,14 +312,23 @@ function ErrorsDialog({ function MissingRanksDialog({ missingRanks, + onContinue: handleContinue, onClose: handleClose, }: { readonly missingRanks: MissingRanks; + readonly onContinue: () => void; readonly onClose: () => void; }): JSX.Element { return ( + {commonText.close()} + + {interactionsText.continue()} + + + } header={batchEditText.missingRanksInQuery()} icon={dialogIcons.info} onClose={handleClose} From 7f70cb448c22efe796585aace5fff65c90508010 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 4 Mar 2025 13:51:12 -0500 Subject: [PATCH 18/43] Fix frontend missing field calculation --- .../js_src/lib/components/BatchEdit/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 81276a9a290..ae6054c519e 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -234,6 +234,13 @@ function findAllMissing(queryFieldSpecs: RA): MissingRanks { // TODO: discuss if we need to add more of them, and if we need to add more of them for other table. const requiredTreeFields: RA = ['name'] as const; +const nameExistsInRanks = ( + name: string, + ranks: RA>> +): boolean => { + return ranks.some((rank) => rank.name === name); +}; + function findMissingRanks( treeTable: SpecifyTable, treeRanks: RA< @@ -276,9 +283,14 @@ function findMissingRanks( return currentTreeRanks.some( (rank) => - rank.specifyRank.name === name && - rank.field !== undefined && - requiredField === rank.field.name + (rank.specifyRank.name === name && + rank.field !== undefined && + requiredField === rank.field.name && + rank.specifyRank.treeDef === treeDef) || + !nameExistsInRanks( + rank.specifyRank.name, + treeDefinition[0].ranks + ) ) ? undefined : `${treeDefinitionName}: ${name} - ${ From 94073764571f30797b05613f7bc59c41a55474b1 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 4 Mar 2025 14:17:34 -0500 Subject: [PATCH 19/43] Fix frontend to many tree error --- specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index ae6054c519e..c346e4a53b2 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -121,7 +121,6 @@ export function BatchEditFromQuery({ onClick={() => { loading( treeRanksPromise.then(async () => { - const missingRanks = findAllMissing(queryFieldSpecs); const invalidFields = queryFieldSpecs.filter((fieldSpec) => filters.some((filter) => filter(fieldSpec)) ); @@ -134,6 +133,7 @@ export function BatchEditFromQuery({ return; } + const missingRanks = findAllMissing(queryFieldSpecs); const newName = batchEditText.datasetName({ queryName: query.get('name'), datePart: new Date().toDateString(), @@ -184,7 +184,7 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { (relationship) => relationship.isRelationship && relationshipIsToMany(relationship) ); - return nestedToManyCount.length > 1; + return nestedToManyCount.length > 0; } const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => From 937e865cb307344be14bef0992477aeba0560ca8 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 4 Mar 2025 19:21:21 +0000 Subject: [PATCH 20/43] Lint code with ESLint and Prettier Triggered by 94073764571f30797b05613f7bc59c41a55474b1 on branch refs/heads/issue-6127 --- specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index c346e4a53b2..0ccb11af62b 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -237,9 +237,7 @@ const requiredTreeFields: RA = ['name'] as const; const nameExistsInRanks = ( name: string, ranks: RA>> -): boolean => { - return ranks.some((rank) => rank.name === name); -}; +): boolean => ranks.some((rank) => rank.name === name); function findMissingRanks( treeTable: SpecifyTable, From ebd827f62c9bf7334260d3f1e373888a3ecfdbc7 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 4 Mar 2025 16:34:32 -0500 Subject: [PATCH 21/43] Restrict to manys only for tree fields --- specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index c346e4a53b2..c3153d37f55 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -184,7 +184,9 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { (relationship) => relationship.isRelationship && relationshipIsToMany(relationship) ); - return nestedToManyCount.length > 0; + + const allowedToMany = isTreeTable(queryFieldSpec.table.name) ? 0 : 1; + return nestedToManyCount.length > allowedToMany; } const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => From bf153f906628e95e43f524c95aa7b674f6528059 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 11 Mar 2025 17:19:11 -0400 Subject: [PATCH 22/43] Fix multiple rank in row error --- specifyweb/workbench/upload/treerecord.py | 45 +---------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index c79c25cc673..93e9bbb5ee1 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -446,23 +446,8 @@ def handle_multiple_or_no_treedefs( elif len(targeted_treedefids) > 1 and len(unique_treedef_ids) > 1: logger.warning(f"Multiple treedefs found in row: {targeted_treedefids}") error_col_name = ranks_columns[0].column_fullname - return self, WorkBenchParseFailure('multipleRanksInRow', {}, error_col_name) - - # Group ranks_columns by treedef_id using itertools.groupby - ranks_columns.sort(key=lambda x: x.treedef_id) # groupby requires sorted input - grouped_by_treedef_id = {k: list(v) for k, v in groupby(ranks_columns, key=lambda x: x.treedef_id)} - - # Check if any treedef_id has more than one rank - multiple_ranks = any(len(columns) > 1 for columns in grouped_by_treedef_id.values()) - if multiple_ranks and len(unique_treedef_ids) > 1: - treedef_id = next( - treedef_id - for treedef_id, columns in grouped_by_treedef_id.items() - if len(columns) > 1 - ) - logger.warning(f"Multiple ranks found for treedef_id {treedef_id}") - error_col_name = grouped_by_treedef_id[treedef_id][0].column_fullname - return self, WorkBenchParseFailure("multipleRanksForTreedef", {}, error_col_name) + + return self, WorkBenchParseFailure('Multiple tree definitions in row', {}, error_col_name) return None @@ -470,32 +455,6 @@ def handle_multiple_or_no_treedefs( def get_target_rank_treedef(tree_def_model, target_rank_treedef_id: int): return tree_def_model.objects.get(id=target_rank_treedef_id) - # Retrieve the treedef items and root for the tree - def get_treedefitems_and_root(tree_rank_model, tree_node_model, target_rank_treedef_id: int): - # Fetch treedef items - def fetch_treedefitems(): - return list(tree_rank_model.objects.filter(treedef_id=target_rank_treedef_id).order_by("rankid")) - - # Fetch root node - def fetch_root(): - return tree_node_model.objects.filter(definition_id=target_rank_treedef_id, parent=None).first() - - # Check if root is None and log warning - def check_root(root): - if root is None: - logger.warning(f"No root found for treedef {target_rank_treedef_id}") - return None, WorkBenchParseFailure('noRoot', {}, None) - return root - - treedefitems = fetch_treedefitems() - root = fetch_root() - root_checked = check_root(root) - - if root_checked is not root: - return root_checked - - return treedefitems, root - tree_def_model, tree_rank_model, tree_node_model = get_models(self.name) unique_treedef_ids = {tr.treedef_id for tr in self.ranks.keys()} From ae42fdd4c301951be1393a4e366cc31dc4e97a1a Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 12 Mar 2025 11:45:56 -0400 Subject: [PATCH 23/43] Fix navigator --- .../js_src/lib/components/BatchEdit/index.tsx | 3 +- .../lib/components/WbPlanView/navigator.ts | 36 +++++++------------ 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 328c9abd2bc..2df0ecf7a8f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -185,8 +185,7 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { relationship.isRelationship && relationshipIsToMany(relationship) ); - const allowedToMany = isTreeTable(queryFieldSpec.table.name) ? 0 : 1; - return nestedToManyCount.length > allowedToMany; + return nestedToManyCount.length > 1; } const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 1dd5a3291a1..d5a6fcd3ce4 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -206,13 +206,6 @@ export type MappingLineData = Pick< readonly defaultValue: string; }; -const queryBuilderTreeFields = new Set([ - 'fullName', - 'author', - 'groupNumber', - 'geographyCode', -]); - /** * Get data required to build a mapping line from a source mapping path * Handles circular dependencies and must match tables @@ -484,8 +477,7 @@ export function getMappingLineData({ ((generateFieldData === 'all' && (!isTreeTable(table.name) || mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(formattedEntry))) || + formatTreeRank(anyTreeRank))) || internalState.defaultValue === formattedEntry) ? ([ formattedEntry, @@ -573,13 +565,6 @@ export function getMappingLineData({ spec.includeReadOnly || !field.overrides.isReadOnly; - isIncluded &&= - spec.includeAllTreeFields || - !isTreeTable(table.name) || - mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(field.name); - // Hide frontend only field isIncluded &&= !( getFrontEndOnlyFields()[table.name]?.includes(field.name) === @@ -588,15 +573,15 @@ export function getMappingLineData({ if (field.isRelationship) { isIncluded &&= - spec.allowNestedToMany || parentRelationship === undefined || (!isCircularRelationship(parentRelationship, field) && - !( - (relationshipIsToMany(field) || - relationshipIsRemoteToOne(field)) && - (relationshipIsToMany(parentRelationship) || - relationshipIsRemoteToOne(parentRelationship)) - )); + (spec.allowNestedToMany || + !( + (relationshipIsToMany(field) || + relationshipIsRemoteToOne(field)) && + (relationshipIsToMany(parentRelationship) || + relationshipIsRemoteToOne(parentRelationship)) + ))); isIncluded &&= !canDoAction || @@ -609,7 +594,10 @@ export function getMappingLineData({ )); isIncluded &&= - spec.includeRelationshipsFromTree || !isTreeTable(table.name); + (spec.includeRelationshipsFromTree && + mappingPath[internalState.position - 1] === + formatTreeRank(anyTreeRank)) || + !isTreeTable(table.name); isIncluded &&= spec.includeToManyToTree || From 28356876e8784a5e795433e3684fe1eb4702cc33 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 12 Mar 2025 12:23:05 -0400 Subject: [PATCH 24/43] Fix tests --- .../WbPlanView/__tests__/navigator.test.ts | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index f57d639e5fe..b9e32ed6b84 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -775,15 +775,6 @@ theories(getMappingLineData, [ optionLabel: '(aggregated)', tableName: 'Determination', }, - collectionObject: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object', - tableName: 'CollectionObject', - }, 'determinedDate-day': { isDefault: false, isEnabled: true, @@ -975,33 +966,6 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'Full Name', }, - acceptedChildren: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Synonyms', - tableName: 'Taxon', - }, - acceptedTaxon: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Preferred Taxon', - tableName: 'Taxon', - }, - children: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Child Taxa', - tableName: 'Taxon', - }, commonName: { isDefault: false, isEnabled: true, @@ -1010,24 +974,6 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'Common Name', }, - definitionItem: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Taxonomic Rank', - tableName: 'TaxonTreeDefItem', - }, - determinations: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Determinations', - tableName: 'Determination', - }, guid: { isDefault: false, isEnabled: true, @@ -1036,24 +982,6 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'GUID', }, - hybridParent1: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Hybrid Parent1', - tableName: 'Taxon', - }, - hybridParent2: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Hybrid Parent2', - tableName: 'Taxon', - }, isAccepted: { isDefault: false, isEnabled: true, @@ -1078,15 +1006,6 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'Name', }, - parent: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Parent of Taxon', - tableName: 'Taxon', - }, rankId: { isDefault: false, isEnabled: true, @@ -1111,24 +1030,6 @@ theories(getMappingLineData, [ isRequired: false, optionLabel: 'Source', }, - taxonAttachments: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Taxon Attachments', - tableName: 'TaxonAttachment', - }, - taxonCitations: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Taxon Citations', - tableName: 'TaxonCitation', - }, }, selectLabel: localized('Taxon'), tableName: 'Taxon', From 7f2149d5eb40851702bde3774bf607cd67a62c5f Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 12 Mar 2025 16:51:14 -0400 Subject: [PATCH 25/43] Group missing ranks by tree --- .../js_src/lib/components/BatchEdit/index.tsx | 100 ++++++++++++------ 1 file changed, 65 insertions(+), 35 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 2df0ecf7a8f..a6178b0530c 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -7,10 +7,10 @@ import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; -import type { RA } from '../../utils/types'; +import type { RA, RR } from '../../utils/types'; import { defined, filterArray } from '../../utils/types'; import { group, keysToLowerCase, sortFunction } from '../../utils/utils'; -import { H2, H3 } from '../Atoms'; +import { H2, H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { dialogIcons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; @@ -20,7 +20,6 @@ import type { SerializedResource, } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { idFromUrl } from '../DataModel/resource'; import { schema } from '../DataModel/schema'; import { serializeResource } from '../DataModel/serializers'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; @@ -138,8 +137,8 @@ export function BatchEditFromQuery({ queryName: query.get('name'), datePart: new Date().toDateString(), }); - const hasMissingRanks = Object.values(missingRanks).some( - (ranks) => ranks.length > 0 + const hasMissingRanks = Object.entries(missingRanks).some( + ([_, rankData]) => Object.values(rankData).length > 0 ); if (hasMissingRanks) { setMissingRanks(missingRanks); @@ -172,9 +171,11 @@ type QueryError = { readonly invalidFields: RA; }; +type TreeDefinitionName = string; + type MissingRanks = { // Query can contain relationship to multiple trees - readonly [KEY in AnyTree['tableName']]: RA; + readonly [KEY in AnyTree['tableName']]: RR>; }; function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { @@ -225,10 +226,12 @@ function findAllMissing(queryFieldSpecs: RA): MissingRanks { ); return Object.fromEntries( - treeFieldSpecs.map(([treeTable, treeRanks]) => [ - treeTable.name, - findMissingRanks(treeTable, treeRanks), - ]) + treeFieldSpecs + .map(([treeTable, treeRanks]) => [ + treeTable.name, + findMissingRanks(treeTable, treeRanks), + ]) + .filter(([_, rankData]) => Object.values(rankData).length > 0) ); } @@ -246,7 +249,7 @@ function findMissingRanks( | { readonly rank: string; readonly field?: LiteralField | Relationship } | undefined > -): RA { +): RR> { const allTreeDefItems = strictGetTreeDefinitionItems( treeTable.name as 'Geography', false, @@ -269,38 +272,59 @@ function findMissingRanks( const highestRank = currentRanksSorted[0]; - return allTreeDefItems.flatMap(({ treeDef, rankId, name }) => + const treeDefinitions = getTreeDefinitions( + treeTable.name as 'Geography', + 'all' + ); + + return Object.fromEntries( + treeDefinitions + .map(({ definition, ranks }) => [ + definition.name, + findMissingRanksInTreeDefItems( + ranks, + treeTable.name, + highestRank, + currentTreeRanks + ), + ]) + .filter(([_, missingRanks]) => missingRanks.length > 0) + ); +} + +type RankData = { + specifyRank: SerializedResource>; + field: LiteralField | Relationship | undefined; +}; + +const findMissingRanksInTreeDefItems = ( + treeDefItems: RA>>, + tableName: string, + highestRank: RankData, + currentTreeRanks: RA +): RA => { + return treeDefItems.flatMap(({ treeDef, rankId, name }) => rankId < highestRank.specifyRank.rankId ? [] : filterArray( requiredTreeFields.map((requiredField) => { - const treeDefinition = getTreeDefinitions( - treeTable.name as 'Geography', - idFromUrl(treeDef) - ); - const treeDefinitionName = treeDefinition[0].definition.name; - return currentTreeRanks.some( (rank) => (rank.specifyRank.name === name && rank.field !== undefined && requiredField === rank.field.name && rank.specifyRank.treeDef === treeDef) || - !nameExistsInRanks( - rank.specifyRank.name, - treeDefinition[0].ranks - ) + !nameExistsInRanks(rank.specifyRank.name, treeDefItems) ) ? undefined - : `${treeDefinitionName}: ${name} - ${ - defined( - strictGetTable(treeTable.name).getField(requiredField) - ).label + : `${name} - ${ + defined(strictGetTable(tableName).getField(requiredField)) + .label }`; }) ) ); -} +}; function ErrorsDialog({ errors, @@ -372,16 +396,13 @@ function ShowMissingRanks({ }: { readonly missingRanks: MissingRanks; }) { - const hasMissing = Object.values(missingRanks).some( - (rank) => rank.length > 0 - ); - return hasMissing ? ( + return (

{batchEditText.addTreeRank()}

{Object.entries(missingRanks).map(([treeTable, ranks]) => ( -
+
{strictGetTable(treeTable).label}
- {ranks.map((rank) => ( -

{rank}

+ {Object.entries(ranks).map(([treeDefName, rankNames]) => ( +
+

{`${treeDefName}:`}

+
    + {rankNames.map((rank) => ( +
  • + {rank} +
  • + ))} +
+
))}
))}
- ) : null; + ); } From 9c46e1587d8ac63193f45087a1afed21b4c77124 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 12 Mar 2025 20:56:17 +0000 Subject: [PATCH 26/43] Lint code with ESLint and Prettier Triggered by 7f2149d5eb40851702bde3774bf607cd67a62c5f on branch refs/heads/issue-6127 --- .../js_src/lib/components/BatchEdit/index.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index a6178b0530c..dd93327e865 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -293,8 +293,8 @@ function findMissingRanks( } type RankData = { - specifyRank: SerializedResource>; - field: LiteralField | Relationship | undefined; + readonly specifyRank: SerializedResource>; + readonly field: LiteralField | Relationship | undefined; }; const findMissingRanksInTreeDefItems = ( @@ -302,13 +302,11 @@ const findMissingRanksInTreeDefItems = ( tableName: string, highestRank: RankData, currentTreeRanks: RA -): RA => { - return treeDefItems.flatMap(({ treeDef, rankId, name }) => +): RA => treeDefItems.flatMap(({ treeDef, rankId, name }) => rankId < highestRank.specifyRank.rankId ? [] : filterArray( - requiredTreeFields.map((requiredField) => { - return currentTreeRanks.some( + requiredTreeFields.map((requiredField) => currentTreeRanks.some( (rank) => (rank.specifyRank.name === name && rank.field !== undefined && @@ -320,11 +318,9 @@ const findMissingRanksInTreeDefItems = ( : `${name} - ${ defined(strictGetTable(tableName).getField(requiredField)) .label - }`; - }) + }`) ) ); -}; function ErrorsDialog({ errors, @@ -416,7 +412,7 @@ function ShowMissingRanks({

{`${treeDefName}:`}

    {rankNames.map((rank) => ( -
  • +
  • {rank}
  • ))} From b1173ffeea802d283c36b3b3a43ab9bfdc96d750 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 13 Mar 2025 15:48:54 -0400 Subject: [PATCH 27/43] Pass filtered treedef ids to the backend - Adds checkboxes to tree names in missing ranks dialog - Splits the main batch edit file into 4 smaller files --- .../lib/components/BatchEdit/MissingRanks.tsx | 124 +++++++ .../lib/components/BatchEdit/QueryError.tsx | 49 +++ .../js_src/lib/components/BatchEdit/index.tsx | 316 +++--------------- .../components/BatchEdit/missingRanksUtils.ts | 148 ++++++++ .../js_src/lib/localization/batchEdit.ts | 4 + 5 files changed, 370 insertions(+), 271 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx new file mode 100644 index 00000000000..bc0fbff84d7 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +import { batchEditText } from '../../localization/batchEdit'; +import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; +import { RR, RA } from '../../utils/types'; +import { H2, H3, Ul } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { Input, Label } from '../Atoms/Form'; +import { dialogIcons } from '../Atoms/Icons'; +import { AnyTree } from '../DataModel/helperTypes'; +import { strictGetTable } from '../DataModel/tables'; +import { getTreeDefinitions } from '../InitialContext/treeRanks'; +import { Dialog } from '../Molecules/Dialog'; +import { TableIcon } from '../Molecules/TableIcon'; + +export type TreeDefinitionName = string; + +export type MissingRanks = { + // Query can contain relationship to multiple trees + readonly [KEY in AnyTree['tableName']]: RR>; +}; + +export function MissingRanksDialog({ + missingRanks, + onSelectTreeDef, + onContinue: handleContinue, + onClose: handleClose, +}: { + readonly missingRanks: MissingRanks; + readonly onSelectTreeDef: ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => void; + readonly onContinue: () => void; + readonly onClose: () => void; +}): JSX.Element { + return ( + + {commonText.close()} + + {interactionsText.continue()} + + + } + header={batchEditText.missingRanksInQuery()} + icon={dialogIcons.info} + onClose={handleClose} + > + + + ); +} + +function ShowMissingRanks({ + missingRanks, + onSelectTreeDef: handleSelectTreeDef, +}: { + readonly missingRanks: MissingRanks; + readonly onSelectTreeDef: ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => void; +}) { + return ( +
    +
    +

    {batchEditText.addTreeRank()}

    +
    + {Object.entries(missingRanks).map(([treeTable, ranks]) => { + const hasMultipleTreeDefs = Object.values(ranks).length > 1; + const treeDefinitions = getTreeDefinitions(treeTable, 'all'); + + return ( +
    +
    + +

    {strictGetTable(treeTable).label}

    +
    + {hasMultipleTreeDefs && ( + {batchEditText.pickTreesToFilter()} + )} +
    + {Object.entries(ranks).map(([treeDefName, rankNames]) => { + const treeDefId = treeDefinitions.find( + ({ definition }) => definition.name === treeDefName + )?.definition.id; + return ( +
    + + {hasMultipleTreeDefs && treeDefId !== undefined ? ( + + handleSelectTreeDef(treeTable, treeDefId) + } + /> + ) : undefined} +

    {`${treeDefName}:`}

    +
    +
      + {rankNames.map((rank) => ( +
    • + {rank} +
    • + ))} +
    +
    + ); + })} +
    +
    + ); + })} +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx new file mode 100644 index 00000000000..03d1f041e5e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { batchEditText } from '../../localization/batchEdit'; +import { commonText } from '../../localization/common'; +import { RA } from '../../utils/types'; +import { H2, H3 } from '../Atoms'; +import { dialogIcons } from '../Atoms/Icons'; +import { Dialog } from '../Molecules/Dialog'; + +export type QueryError = { + readonly invalidFields: RA; +}; + +export function ErrorsDialog({ + errors, + onClose: handleClose, +}: { + readonly errors: QueryError; + readonly onClose: () => void; +}): JSX.Element { + return ( + + + + ); +} + +function ShowInvalidFields({ + error, +}: { + readonly error: QueryError['invalidFields']; +}) { + const hasErrors = error.length > 0; + return hasErrors ? ( +
    +
    +

    {batchEditText.removeField()}

    +
    + {error.map((singleError) => ( +

    {singleError}

    + ))} +
    + ) : null; +} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index a6178b0530c..c35b3e6bef8 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -3,46 +3,27 @@ import { useNavigate } from 'react-router-dom'; import type { LocalizedString } from 'typesafe-i18n'; import { batchEditText } from '../../localization/batchEdit'; -import { commonText } from '../../localization/common'; -import { interactionsText } from '../../localization/interactions'; import { ajax } from '../../utils/ajax'; -import { f } from '../../utils/functools'; -import type { RA, RR } from '../../utils/types'; -import { defined, filterArray } from '../../utils/types'; -import { group, keysToLowerCase, sortFunction } from '../../utils/utils'; -import { H2, H3, Ul } from '../Atoms'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { keysToLowerCase } from '../../utils/utils'; import { Button } from '../Atoms/Button'; -import { dialogIcons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; -import type { - AnyTree, - FilterTablesByEndsWith, - SerializedResource, -} from '../DataModel/helperTypes'; +import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; import { serializeResource } from '../DataModel/serializers'; -import type { LiteralField, Relationship } from '../DataModel/specifyField'; -import type { SpecifyTable } from '../DataModel/specifyTable'; -import { strictGetTable } from '../DataModel/tables'; import type { SpQuery, Tables } from '../DataModel/types'; -import { - getTreeDefinitions, - isTreeTable, - strictGetTreeDefinitionItems, - treeRanksPromise, -} from '../InitialContext/treeRanks'; -import { Dialog } from '../Molecules/Dialog'; -import { TableIcon } from '../Molecules/TableIcon'; +import { treeRanksPromise } from '../InitialContext/treeRanks'; import { userPreferences } from '../Preferences/userPreferences'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; import type { QueryField } from '../QueryBuilder/helpers'; import { uniquifyDataSetName } from '../WbImport/helpers'; -import { - anyTreeRank, - relationshipIsToMany, -} from '../WbPlanView/mappingHelpers'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; +import { MissingRanks, MissingRanksDialog } from './MissingRanks'; +import { findAllMissing } from './missingRanksUtils'; +import { ErrorsDialog, QueryError } from './QueryError'; const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => generateMappingPathPreview( @@ -50,6 +31,13 @@ const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => queryFieldSpec.toMappingPath() ); +// Data structure for passing the treedefs to use for batch edit in case of missing ranks +type TreeDefsFilter = + | { + readonly [KEY in AnyTree['tableName']]: RA; + } + | {}; + export function BatchEditFromQuery({ query, fields, @@ -77,12 +65,16 @@ export function BatchEditFromQuery({ name: dataSetName, recordSetId, limit: userPreferences.get('batchEdit', 'query', 'limit'), + treeDefsFilter: treeDefsFilter, }), }); - const [errors, setErrors] = React.useState(undefined); + const [errors, setErrors] = React.useState(); const [missingRanks, setMissingRanks] = React.useState(); const [datasetName, setDatasetName] = React.useState(); + const [treeDefsFilter, setTreeDefsFilter] = React.useState( + {} + ); const loading = React.useContext(LoadingContext); const queryFieldSpecs = React.useMemo( @@ -97,6 +89,28 @@ export function BatchEditFromQuery({ [fields] ); + const handleCheckboxChange = ( + treeTableName: AnyTree['tableName'], + treeDefId: number + ) => { + setTreeDefsFilter((prevFilter) => { + const updatedFilter = { ...prevFilter }; + if (Array.isArray(updatedFilter[treeTableName])) { + if (updatedFilter[treeTableName]?.includes(treeDefId)) { + updatedFilter[treeTableName] = updatedFilter[treeTableName]?.filter( + (id) => id !== treeDefId + ); + } else { + updatedFilter[treeTableName] = + updatedFilter[treeTableName]?.concat(treeDefId); + } + } else { + updatedFilter[treeTableName] = [treeDefId]; + } + return updatedFilter; + }); + }; + const handleCloseDialog = () => { setDatasetName(undefined); setMissingRanks(undefined); @@ -159,6 +173,7 @@ export function BatchEditFromQuery({ {missingRanks !== undefined && datasetName !== undefined ? ( loading(handleCreateDataset(datasetName))} /> @@ -167,17 +182,6 @@ export function BatchEditFromQuery({ ); } -type QueryError = { - readonly invalidFields: RA; -}; - -type TreeDefinitionName = string; - -type MissingRanks = { - // Query can contain relationship to multiple trees - readonly [KEY in AnyTree['tableName']]: RR>; -}; - function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { const joinPath = queryFieldSpec.joinPath; if (joinPath.length <= 1) return false; @@ -197,235 +201,5 @@ const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) => queryFieldSpec.baseTable.name.toLowerCase() as 'collection' ); +// Error filters const filters = [containsFaultyNestedToMany, containsSystemTables]; - -const getTreeDefFromName = ( - rankName: string, - treeDefItems: RA>> -) => - defined( - treeDefItems.find( - (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() - ) - ); - -function findAllMissing(queryFieldSpecs: RA): MissingRanks { - const treeFieldSpecs = group( - filterArray( - queryFieldSpecs.map((fieldSpec) => - isTreeTable(fieldSpec.table.name) && - fieldSpec.treeRank !== anyTreeRank && - fieldSpec.treeRank !== undefined - ? [ - fieldSpec.table, - { rank: fieldSpec.treeRank, field: fieldSpec.getField() }, - ] - : undefined - ) - ) - ); - - return Object.fromEntries( - treeFieldSpecs - .map(([treeTable, treeRanks]) => [ - treeTable.name, - findMissingRanks(treeTable, treeRanks), - ]) - .filter(([_, rankData]) => Object.values(rankData).length > 0) - ); -} - -// TODO: discuss if we need to add more of them, and if we need to add more of them for other table. -const requiredTreeFields: RA = ['name'] as const; - -const nameExistsInRanks = ( - name: string, - ranks: RA>> -): boolean => ranks.some((rank) => rank.name === name); - -function findMissingRanks( - treeTable: SpecifyTable, - treeRanks: RA< - | { readonly rank: string; readonly field?: LiteralField | Relationship } - | undefined - > -): RR> { - const allTreeDefItems = strictGetTreeDefinitionItems( - treeTable.name as 'Geography', - false, - 'all' - ); - - // Duplicates don't affect any logic here - const currentTreeRanks = filterArray( - treeRanks.map((treeRank) => - f.maybe(treeRank, ({ rank, field }) => ({ - specifyRank: getTreeDefFromName(rank, allTreeDefItems), - field, - })) - ) - ); - - const currentRanksSorted = Array.from(currentTreeRanks).sort( - sortFunction(({ specifyRank: { rankId } }) => rankId) - ); - - const highestRank = currentRanksSorted[0]; - - const treeDefinitions = getTreeDefinitions( - treeTable.name as 'Geography', - 'all' - ); - - return Object.fromEntries( - treeDefinitions - .map(({ definition, ranks }) => [ - definition.name, - findMissingRanksInTreeDefItems( - ranks, - treeTable.name, - highestRank, - currentTreeRanks - ), - ]) - .filter(([_, missingRanks]) => missingRanks.length > 0) - ); -} - -type RankData = { - specifyRank: SerializedResource>; - field: LiteralField | Relationship | undefined; -}; - -const findMissingRanksInTreeDefItems = ( - treeDefItems: RA>>, - tableName: string, - highestRank: RankData, - currentTreeRanks: RA -): RA => { - return treeDefItems.flatMap(({ treeDef, rankId, name }) => - rankId < highestRank.specifyRank.rankId - ? [] - : filterArray( - requiredTreeFields.map((requiredField) => { - return currentTreeRanks.some( - (rank) => - (rank.specifyRank.name === name && - rank.field !== undefined && - requiredField === rank.field.name && - rank.specifyRank.treeDef === treeDef) || - !nameExistsInRanks(rank.specifyRank.name, treeDefItems) - ) - ? undefined - : `${name} - ${ - defined(strictGetTable(tableName).getField(requiredField)) - .label - }`; - }) - ) - ); -}; - -function ErrorsDialog({ - errors, - onClose: handleClose, -}: { - readonly errors: QueryError; - readonly onClose: () => void; -}): JSX.Element { - return ( - - - - ); -} - -function MissingRanksDialog({ - missingRanks, - onContinue: handleContinue, - onClose: handleClose, -}: { - readonly missingRanks: MissingRanks; - readonly onContinue: () => void; - readonly onClose: () => void; -}): JSX.Element { - return ( - - {commonText.close()} - - {interactionsText.continue()} - - - } - header={batchEditText.missingRanksInQuery()} - icon={dialogIcons.info} - onClose={handleClose} - > - - - ); -} - -function ShowInvalidFields({ - error, -}: { - readonly error: QueryError['invalidFields']; -}) { - const hasErrors = error.length > 0; - return hasErrors ? ( -
    -
    -

    {batchEditText.removeField()}

    -
    - {error.map((singleError) => ( -

    {singleError}

    - ))} -
    - ) : null; -} - -function ShowMissingRanks({ - missingRanks, -}: { - readonly missingRanks: MissingRanks; -}) { - return ( -
    -
    -

    {batchEditText.addTreeRank()}

    -
    - {Object.entries(missingRanks).map(([treeTable, ranks]) => ( -
    -
    - -

    {strictGetTable(treeTable).label}

    -
    -
    - {Object.entries(ranks).map(([treeDefName, rankNames]) => ( -
    -

    {`${treeDefName}:`}

    -
      - {rankNames.map((rank) => ( -
    • - {rank} -
    • - ))} -
    -
    - ))} -
    -
    - ))} -
    - ); -} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts new file mode 100644 index 00000000000..dcd0793db6e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts @@ -0,0 +1,148 @@ +import { f } from '../../utils/functools'; +import { RA, defined, filterArray, RR } from '../../utils/types'; +import { group, sortFunction } from '../../utils/utils'; +import { + SerializedResource, + FilterTablesByEndsWith, + AnyTree, +} from '../DataModel/helperTypes'; +import { LiteralField, Relationship } from '../DataModel/specifyField'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import { + isTreeTable, + strictGetTreeDefinitionItems, + getTreeDefinitions, +} from '../InitialContext/treeRanks'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { anyTreeRank } from '../WbPlanView/mappingHelpers'; +import { MissingRanks, TreeDefinitionName } from './MissingRanks'; + +const getTreeDefFromName = ( + rankName: string, + treeDefItems: RA>> +) => + defined( + treeDefItems.find( + (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() + ) + ); + +export function findAllMissing( + queryFieldSpecs: RA +): MissingRanks { + const treeFieldSpecs = group( + filterArray( + queryFieldSpecs.map((fieldSpec) => + isTreeTable(fieldSpec.table.name) && + fieldSpec.treeRank !== anyTreeRank && + fieldSpec.treeRank !== undefined + ? [ + fieldSpec.table, + { rank: fieldSpec.treeRank, field: fieldSpec.getField() }, + ] + : undefined + ) + ) + ); + + return Object.fromEntries( + treeFieldSpecs + .map(([treeTable, treeRanks]) => [ + treeTable.name, + findMissingRanks(treeTable, treeRanks), + ]) + .filter(([_, rankData]) => Object.values(rankData).length > 0) + ); +} + +// TODO: discuss if we need to add more of them, and if we need to add more of them for other table. +const requiredTreeFields: RA = ['name'] as const; + +const nameExistsInRanks = ( + name: string, + ranks: RA>> +): boolean => ranks.some((rank) => rank.name === name); + +function findMissingRanks( + treeTable: SpecifyTable, + treeRanks: RA< + | { readonly rank: string; readonly field?: LiteralField | Relationship } + | undefined + > +): RR> { + const allTreeDefItems = strictGetTreeDefinitionItems( + treeTable.name as 'Geography', + false, + 'all' + ); + + // Duplicates don't affect any logic here + const currentTreeRanks = filterArray( + treeRanks.map((treeRank) => + f.maybe(treeRank, ({ rank, field }) => ({ + specifyRank: getTreeDefFromName(rank, allTreeDefItems), + field, + })) + ) + ); + + const currentRanksSorted = Array.from(currentTreeRanks).sort( + sortFunction(({ specifyRank: { rankId } }) => rankId) + ); + + const highestRank = currentRanksSorted[0]; + + const treeDefinitions = getTreeDefinitions( + treeTable.name as 'Geography', + 'all' + ); + + return Object.fromEntries( + treeDefinitions + .map(({ definition, ranks }) => [ + definition.name, + findMissingRanksInTreeDefItems( + ranks, + treeTable.name, + highestRank, + currentTreeRanks + ), + ]) + .filter(([_, missingRanks]) => missingRanks.length > 0) + ); +} + +type RankData = { + specifyRank: SerializedResource>; + field: LiteralField | Relationship | undefined; +}; + +const findMissingRanksInTreeDefItems = ( + treeDefItems: RA>>, + tableName: string, + highestRank: RankData, + currentTreeRanks: RA +): RA => { + return treeDefItems.flatMap(({ treeDef, rankId, name }) => + rankId < highestRank.specifyRank.rankId + ? [] + : filterArray( + requiredTreeFields.map((requiredField) => { + return currentTreeRanks.some( + (rank) => + (rank.specifyRank.name === name && + rank.field !== undefined && + requiredField === rank.field.name && + rank.specifyRank.treeDef === treeDef) || + !nameExistsInRanks(rank.specifyRank.name, treeDefItems) + ) + ? undefined + : `${name} - ${ + defined(strictGetTable(tableName).getField(requiredField)) + .label + }`; + }) + ) + ); +}; diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index bd9535b771d..d8a75b4fd45 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -21,6 +21,10 @@ export const batchEditText = createDictionary({ 'en-us': 'The following ranks will be added to the query to enable batch editing', }, + pickTreesToFilter: { + 'en-us': + 'The selected rank(s) are found in multiple trees. Pick tree(s) to batch edit with', + }, datasetName: { 'en-us': '{queryName:string} {datePart:string}', }, From d09d325670d7ec15a958fc8e68dd5c0573f20ed2 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 13 Mar 2025 19:55:05 +0000 Subject: [PATCH 28/43] Lint code with ESLint and Prettier Triggered by 1c29ec993fc7d111e6c9f24004eb79657b704798 on branch refs/heads/issue-6127 --- .../lib/components/BatchEdit/MissingRanks.tsx | 6 +-- .../lib/components/BatchEdit/QueryError.tsx | 2 +- .../js_src/lib/components/BatchEdit/index.tsx | 27 +++++++------ .../components/BatchEdit/missingRanksUtils.ts | 38 ++++++++++--------- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx index bc0fbff84d7..e73d0c2a7fc 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; -import { RR, RA } from '../../utils/types'; +import type { RA,RR } from '../../utils/types'; import { H2, H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import { dialogIcons } from '../Atoms/Icons'; -import { AnyTree } from '../DataModel/helperTypes'; +import type { AnyTree } from '../DataModel/helperTypes'; import { strictGetTable } from '../DataModel/tables'; import { getTreeDefinitions } from '../InitialContext/treeRanks'; import { Dialog } from '../Molecules/Dialog'; @@ -107,7 +107,7 @@ function ShowMissingRanks({
      {rankNames.map((rank) => ( -
    • +
    • {rank}
    • ))} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx index 03d1f041e5e..4aefa79910d 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/QueryError.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; -import { RA } from '../../utils/types'; +import type { RA } from '../../utils/types'; import { H2, H3 } from '../Atoms'; import { dialogIcons } from '../Atoms/Icons'; import { Dialog } from '../Molecules/Dialog'; diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index c35b3e6bef8..a7b8d1397a5 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -21,9 +21,11 @@ import type { QueryField } from '../QueryBuilder/helpers'; import { uniquifyDataSetName } from '../WbImport/helpers'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; -import { MissingRanks, MissingRanksDialog } from './MissingRanks'; +import type { MissingRanks } from './MissingRanks'; +import { MissingRanksDialog } from './MissingRanks'; import { findAllMissing } from './missingRanksUtils'; -import { ErrorsDialog, QueryError } from './QueryError'; +import type { QueryError } from './QueryError'; +import { ErrorsDialog } from './QueryError'; const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec) => generateMappingPathPreview( @@ -65,7 +67,7 @@ export function BatchEditFromQuery({ name: dataSetName, recordSetId, limit: userPreferences.get('batchEdit', 'query', 'limit'), - treeDefsFilter: treeDefsFilter, + treeDefsFilter, }), }); @@ -93,17 +95,14 @@ export function BatchEditFromQuery({ treeTableName: AnyTree['tableName'], treeDefId: number ) => { - setTreeDefsFilter((prevFilter) => { - const updatedFilter = { ...prevFilter }; + setTreeDefsFilter((previousFilter) => { + const updatedFilter = { ...previousFilter }; if (Array.isArray(updatedFilter[treeTableName])) { - if (updatedFilter[treeTableName]?.includes(treeDefId)) { - updatedFilter[treeTableName] = updatedFilter[treeTableName]?.filter( - (id) => id !== treeDefId - ); - } else { - updatedFilter[treeTableName] = - updatedFilter[treeTableName]?.concat(treeDefId); - } + updatedFilter[treeTableName] = updatedFilter[treeTableName]?.includes( + treeDefId + ) + ? updatedFilter[treeTableName]?.filter((id) => id !== treeDefId) + : updatedFilter[treeTableName]?.concat(treeDefId); } else { updatedFilter[treeTableName] = [treeDefId]; } @@ -173,9 +172,9 @@ export function BatchEditFromQuery({ {missingRanks !== undefined && datasetName !== undefined ? ( loading(handleCreateDataset(datasetName))} + onSelectTreeDef={handleCheckboxChange} /> ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts index dcd0793db6e..dd3fc0a7144 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/missingRanksUtils.ts @@ -1,22 +1,23 @@ import { f } from '../../utils/functools'; -import { RA, defined, filterArray, RR } from '../../utils/types'; +import type { RA, RR } from '../../utils/types'; +import { defined, filterArray } from '../../utils/types'; import { group, sortFunction } from '../../utils/utils'; -import { - SerializedResource, - FilterTablesByEndsWith, +import type { AnyTree, + FilterTablesByEndsWith, + SerializedResource, } from '../DataModel/helperTypes'; -import { LiteralField, Relationship } from '../DataModel/specifyField'; -import { SpecifyTable } from '../DataModel/specifyTable'; +import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import { + getTreeDefinitions, isTreeTable, strictGetTreeDefinitionItems, - getTreeDefinitions, } from '../InitialContext/treeRanks'; -import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import type { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; import { anyTreeRank } from '../WbPlanView/mappingHelpers'; -import { MissingRanks, TreeDefinitionName } from './MissingRanks'; +import type { MissingRanks, TreeDefinitionName } from './MissingRanks'; const getTreeDefFromName = ( rankName: string, @@ -114,8 +115,10 @@ function findMissingRanks( } type RankData = { - specifyRank: SerializedResource>; - field: LiteralField | Relationship | undefined; + readonly specifyRank: SerializedResource< + FilterTablesByEndsWith<'TreeDefItem'> + >; + readonly field: LiteralField | Relationship | undefined; }; const findMissingRanksInTreeDefItems = ( @@ -123,13 +126,13 @@ const findMissingRanksInTreeDefItems = ( tableName: string, highestRank: RankData, currentTreeRanks: RA -): RA => { - return treeDefItems.flatMap(({ treeDef, rankId, name }) => +): RA => + treeDefItems.flatMap(({ treeDef, rankId, name }) => rankId < highestRank.specifyRank.rankId ? [] : filterArray( - requiredTreeFields.map((requiredField) => { - return currentTreeRanks.some( + requiredTreeFields.map((requiredField) => + currentTreeRanks.some( (rank) => (rank.specifyRank.name === name && rank.field !== undefined && @@ -141,8 +144,7 @@ const findMissingRanksInTreeDefItems = ( : `${name} - ${ defined(strictGetTable(tableName).getField(requiredField)) .label - }`; - }) + }` + ) ) ); -}; From e8d138ed0fc47a484156c0b3aa1f16d9b701b81a Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 13 Mar 2025 15:56:39 -0400 Subject: [PATCH 29/43] Filter trees used when rewriting batch edit dataset --- specifyweb/specify/func.py | 2 +- specifyweb/stored_queries/batch_edit.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 7c60e554808..7b61dc3838f 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -51,7 +51,7 @@ def second(source: List[Tuple[I, O]]) -> List[O]: return [second for (_, second) in source] @staticmethod - def filter_list(source: List[Optional[I]])->List[I]: + def filter_list(source: List[Optional[I]]) -> List[I]: return [item for item in source if item is not None] class CustomRepr: diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index bba0e98a60c..2bcecb1e8cc 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1008,6 +1008,7 @@ def run_batch_edit(collection, user, spquery, agent): fields=fields_from_json(spquery["fields"]), session_maker=models.session_context, omit_relationships=True, + treedefsfilter=spquery.get("treedefsfilter", None) ) (headers, rows, packs, json_upload_plan, visual_order) = run_batch_edit_query(props) mapped_raws = [ @@ -1037,6 +1038,7 @@ class BatchEditProps(TypedDict): session_maker: Any fields: List[QueryField] omit_relationships: Optional[bool] + treedefsfilter: Any def _get_table_and_field(field: QueryField): table_name = field.fieldspec.table.name @@ -1055,6 +1057,8 @@ def run_batch_edit_query(props: BatchEditProps): visible_fields = [field for field in fields if field.display] + treedefsfilter = props["treedefsfilter"] + assert captions is None or ( len(visible_fields) == len(captions) ), "Got misaligned captions!" @@ -1071,6 +1075,9 @@ def run_batch_edit_query(props: BatchEditProps): all_tree_info = get_all_tree_information(props["collection"], props["user"].id) base_table = datamodel.get_table_by_id_strict(tableid, strict=True) running_path = [base_table.name] + + if treedefsfilter is not None: + all_tree_info = filter_tree_info(treedefsfilter, all_tree_info) row_plan = naive_row_plan.rewrite(base_table, all_tree_info, running_path) indexed, query_fields = row_plan.index_plan() @@ -1212,4 +1219,14 @@ def make_dataset( # Create the backer. ds.save() - return (ds_id, ds_name) \ No newline at end of file + return (ds_id, ds_name) + + +def filter_tree_info(filters: Dict[str, List[int]], all_tree_info: Dict[str, List[TREE_INFORMATION]]): + for tablename in filters: + treetable_key = tablename.title() + if treetable_key in all_tree_info: + tree_filter = set(filters[tablename]) + all_tree_info[treetable_key] = list(filter(lambda tree_info : tree_info['definition']['id'] in tree_filter, all_tree_info[treetable_key])) + + return all_tree_info \ No newline at end of file From 1589ed8516a42259cf493958bce0a28ab7420ad5 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 13 Mar 2025 16:51:57 -0400 Subject: [PATCH 30/43] Fix tests --- specifyweb/stored_queries/tests/test_batch_edit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index 5aa34cde7ec..e3b34caee42 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -38,7 +38,8 @@ def _builder(query_fields, base_table): captions=None, limit=None, recordsetid=None, - omit_relationships=False + omit_relationships=False, + treedefsfilter=None ) return _builder From 8ac0c4aa6d437adbb6be1dbdc79f1c3ae1dbd0a5 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 11 Mar 2025 15:09:52 -0400 Subject: [PATCH 31/43] Use TreeRankRecord in upload plan --- .../workbench/upload/tests/example_plan.py | 54 +++++++++++++++---- .../workbench/upload/tests/testschema.py | 15 ++++-- specifyweb/workbench/upload/treerecord.py | 2 +- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/specifyweb/workbench/upload/tests/example_plan.py b/specifyweb/workbench/upload/tests/example_plan.py index cc548b96823..7d2987e9f5f 100644 --- a/specifyweb/workbench/upload/tests/example_plan.py +++ b/specifyweb/workbench/upload/tests/example_plan.py @@ -34,11 +34,31 @@ )}, 'taxon': { 'treeRecord': dict( ranks = { - 'Class': 'Class', - 'Superfamily': 'Superfamily', - 'Family': 'Family', - 'Genus': 'Genus', - 'Subgenus': 'Subgenus', + 'Class': dict( + treeNodeCols = { + 'name': 'Class', + }, + ), + 'Superfamily': dict( + treeNodeCols = { + 'name': 'Superfamily', + }, + ), + 'Family': dict( + treeNodeCols = { + 'name': 'Family', + }, + ), + 'Genus': dict( + treeNodeCols = { + 'name': 'Genus', + }, + ), + 'Subgenus': dict( + treeNodeCols = { + 'name': 'Subgenus', + }, + ), 'Species': dict( treeNodeCols = { 'name': 'Species', @@ -77,10 +97,26 @@ toOne = { 'geography': { 'treeRecord': dict( ranks = { - 'Continent': 'Continent/Ocean' , - 'Country': 'Country', - 'State': 'State/Prov/Pref', - 'County': 'Region', + 'Continent': dict( + treeNodeCols = { + 'name': 'Continent/Ocean', + }, + ), + 'Country': dict( + treeNodeCols = { + 'name': 'Country', + }, + ), + 'State': dict( + treeNodeCols = { + 'name': 'State/Prov/Pref', + }, + ), + 'County': dict( + treeNodeCols = { + 'name': 'Region', + }, + ), } )}, }, diff --git a/specifyweb/workbench/upload/tests/testschema.py b/specifyweb/workbench/upload/tests/testschema.py index 2fcdda21df4..43a7e28558f 100644 --- a/specifyweb/workbench/upload/tests/testschema.py +++ b/specifyweb/workbench/upload/tests/testschema.py @@ -13,11 +13,18 @@ def set_plan_treeId(): # Set the treeId in example_plan.json dynamically, so that the unit test doesn't depend on the static treeId to always be the same. - from specifyweb.specify.models import Taxontreedefitem - tree_id = Taxontreedefitem.objects.filter(name='Species').first().treedef_id + def set_tree_id_for_ranks(ranks, model, name): + tree_id = model.objects.filter(name=name).first().treedef_id + for rank in ranks.keys(): + ranks[rank]['treeId'] = tree_id + + from specifyweb.specify.models import Taxontreedefitem, Geographytreedefitem + example_plan_ranks = example_plan.json['uploadable']['uploadTable']['toMany']['determinations'][0]['toOne']['taxon']['treeRecord']['ranks'] - for rank in ['Species', 'Subspecies']: - example_plan_ranks[rank]['treeId'] = tree_id + set_tree_id_for_ranks(example_plan_ranks, Taxontreedefitem, 'Species') + + geography_ranks = example_plan.json['uploadable']['uploadTable']['toOne']['collectingevent']['uploadTable']['toOne']['locality']['uploadTable']['toOne']['geography']['treeRecord']['ranks'] + set_tree_id_for_ranks(geography_ranks, Geographytreedefitem, 'Continent') class SchemaTests(UploadTestsBase): maxDiff = None diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 9bf2738c11d..94d24d93109 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -151,7 +151,7 @@ def to_json(self) -> Dict: rank_key = rank.rank_name if isinstance(rank, TreeRankRecord) else rank treeNodeCols = {k: v.to_json() if hasattr(v, "to_json") else v for k, v in cols.items()} - if len(cols) == 1: + if len(cols) == 1 and not isinstance(rank, TreeRankRecord): result["ranks"][rank_key] = treeNodeCols["name"] else: rank_data = {"treeNodeCols": treeNodeCols} From 06f52326724440d5c7dd8c681935067b39b07de0 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 27 Mar 2025 14:48:21 -0400 Subject: [PATCH 32/43] Remove unused string --- specifyweb/frontend/js_src/lib/localization/batchEdit.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 28662999ffb..d8a75b4fd45 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -88,7 +88,4 @@ export const batchEditText = createDictionary({ batchEditRecordSetName: { 'en-us': 'BE commit of "{dataSet:string}"', }, - treeQueriesDisabled: { - 'en-us': 'Batch editing is disabled for trees', - }, } as const); From a8a2ad6c322187e248019065b91a4ed1f9fd27d0 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Thu, 27 Mar 2025 15:31:48 -0400 Subject: [PATCH 33/43] Fix visual order - For multiple trees, columns will be grouped by tree first --- specifyweb/stored_queries/batch_edit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index ce18a2cfa7a..52a40b85358 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1169,7 +1169,7 @@ def _get_orig_column(string_id: str): # We would have arbitarily sorted the columns, so our columns will not be correct. # Rather than sifting the data, we just add a default visual order. - visual_order = Func.first(sorted(headers_enumerated, key=lambda tup: tup[1][0])) + visual_order = Func.first(headers_enumerated) headers = Func.second(key_and_headers) From ca8b498eb7bf0c7318574f8004908f36d7bf0b0e Mon Sep 17 00:00:00 2001 From: Sharad S Date: Fri, 28 Mar 2025 14:31:09 -0400 Subject: [PATCH 34/43] Revert "Fix visual order" This reverts commit a8a2ad6c322187e248019065b91a4ed1f9fd27d0. --- specifyweb/stored_queries/batch_edit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 52a40b85358..ce18a2cfa7a 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1169,7 +1169,7 @@ def _get_orig_column(string_id: str): # We would have arbitarily sorted the columns, so our columns will not be correct. # Rather than sifting the data, we just add a default visual order. - visual_order = Func.first(headers_enumerated) + visual_order = Func.first(sorted(headers_enumerated, key=lambda tup: tup[1][0])) headers = Func.second(key_and_headers) From a6e0d4e56c05a048a62cc5e5872cb6d46102cbc2 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Tue, 15 Apr 2025 13:10:20 -0400 Subject: [PATCH 35/43] Flag to-many in tree only queries --- .../frontend/js_src/lib/components/BatchEdit/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index a7b8d1397a5..48220f4716f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -14,7 +14,7 @@ import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; import { serializeResource } from '../DataModel/serializers'; import type { SpQuery, Tables } from '../DataModel/types'; -import { treeRanksPromise } from '../InitialContext/treeRanks'; +import { isTreeTable, treeRanksPromise } from '../InitialContext/treeRanks'; import { userPreferences } from '../Preferences/userPreferences'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; import type { QueryField } from '../QueryBuilder/helpers'; @@ -189,7 +189,12 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec): boolean { relationship.isRelationship && relationshipIsToMany(relationship) ); - return nestedToManyCount.length > 1; + const isTreeOnlyQuery = + isTreeTable(queryFieldSpec.baseTable.name) && + isTreeTable(queryFieldSpec.table.name); + + const allowedToMany = isTreeOnlyQuery ? 0 : 1; + return nestedToManyCount.length > allowedToMany; } const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => From b31b8a711c6f0c4b08bf780685f7c469b917484d Mon Sep 17 00:00:00 2001 From: Sharad S <16229739+sharadsw@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:29:17 +0000 Subject: [PATCH 36/43] Lint code with ESLint and Prettier Triggered by f36521c19fde1e7ebf85fc38697d38f69e5981bc on branch refs/heads/issue-6127 --- .../FormSliders/IntegratedRecordSelector.tsx | 482 +++++++++--------- .../components/Interactions/PrepDialog.tsx | 3 +- .../components/Interactions/PrepDialogRow.tsx | 6 +- .../js_src/lib/components/Reports/index.tsx | 6 +- .../lib/components/SearchDialog/index.tsx | 18 +- .../js_src/lib/localization/attachments.ts | 6 +- .../js_src/lib/localization/interactions.ts | 4 +- 7 files changed, 266 insertions(+), 259 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index 2970054414e..08a0e65e2f7 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -169,261 +169,263 @@ export function IntegratedRecordSelector({ isLoanPrep && (collection.related?.isNew() === true || collection.related?.needsSaved); - const [isWarningOpen, handleWarning,closeWarning] = useBooleanState() + const [isWarningOpen, handleWarning, closeWarning] = useBooleanState(); - return ( - - { - if (isInteraction) { - setInteractionResource(resources[0]); - handleOpenDialog(); - } - if (!isInteraction && formType !== 'formTable') - collection.add(resources); - handleAdding(resources); - }} - onDelete={(...args): void => { - if (isCollapsed) handleExpand(); - handleDelete?.(...args); - }} - onFetch={handleFetch} - onSlide={(index): void => { - handleExpand(); - if (typeof urlParameter === 'string') setIndex(index.toString()); - }} - {...rest} - > - {({ - dialogs, - slider, - resource, - onAdd: handleAdd, - onRemove: handleRemove, - showSearchDialog, - isLoading, - }): JSX.Element => ( - <> - {isInteraction && - typeof collection.related === 'object' && - isDialogOpen ? ( - - } - interactionResource={interactionResource} - itemCollection={ - collection as Collection - } - onClose={handleCloseDialog} - /> - ) : undefined} - {formType === 'form' ? ( - - ( - <> - + { + if (isInteraction) { + setInteractionResource(resources[0]); + handleOpenDialog(); + } + if (!isInteraction && formType !== 'formTable') + collection.add(resources); + handleAdding(resources); + }} + onDelete={(...args): void => { + if (isCollapsed) handleExpand(); + handleDelete?.(...args); + }} + onFetch={handleFetch} + onSlide={(index): void => { + handleExpand(); + if (typeof urlParameter === 'string') setIndex(index.toString()); + }} + {...rest} + > + {({ + dialogs, + slider, + resource, + onAdd: handleAdd, + onRemove: handleRemove, + showSearchDialog, + isLoading, + }): JSX.Element => ( + <> + {isInteraction && + typeof collection.related === 'object' && + isDialogOpen ? ( + + } + interactionResource={interactionResource} + itemCollection={ + collection as Collection + } + onClose={handleCloseDialog} + /> + ) : undefined} + {formType === 'form' ? ( + + ( + <> + + {!isDependent && + hasTablePermission( + relationship.relatedTable.name, + 'read' + ) && + typeof handleAdd === 'function' ? ( + 0) || + isTaxonTreeDefItemTable } + onClick={showSearchDialog} /> - {!isDependent && - hasTablePermission( - relationship.relatedTable.name, - 'read' - ) && - typeof handleAdd === 'function' ? ( - 0) || - isTaxonTreeDefItemTable + ) : undefined} + {hasTablePermission( + relationship.relatedTable.name, + 'create' + ) && typeof handleAdd === 'function' ? ( + isCOJO ? ( + } - onClick={showSearchDialog} /> - ) : undefined} - {hasTablePermission( - relationship.relatedTable.name, - 'create' - ) && typeof handleAdd === 'function' ? ( - isCOJO ? ( - - } - /> - ) : ( - 0) || - isTaxonTreeDefItemTable - } - onClick={(): void => { - const resource = - new collection.table.specifyTable.Resource(); - - if ( - isDependent || - viewName === relationship.relatedTable.view - ) { - focusFirstField(); - handleAdd([resource]); - return; - } - - if (state.type === 'AddResourceState') - setState({ type: 'MainState' }); - else - setState({ - type: 'AddResourceState', - resource, - handleAdd, - }); - }} - /> - ) - ) : undefined} - {hasTablePermission( - relationship.relatedTable.name, - isDependent ? 'delete' : 'read' - ) && typeof handleRemove === 'function' ? ( - 0) || + isTaxonTreeDefItemTable } onClick={(): void => { - if (isAttachmentTable) { - handleWarning() - } else { - handleRemove('minusButton'); + const resource = + new collection.table.specifyTable.Resource(); + + if ( + isDependent || + viewName === relationship.relatedTable.view + ) { + focusFirstField(); + handleAdd([resource]); + return; } + + if (state.type === 'AddResourceState') + setState({ type: 'MainState' }); + else + setState({ + type: 'AddResourceState', + resource, + handleAdd, + }); }} /> - ) : undefined} - { + if (isAttachmentTable) { + handleWarning(); + } else { + handleRemove('minusButton'); + } + }} /> - {isAttachmentTable && ( - - )} - {specifyNetworkBadge} - {!isToOne && slider} - - )} - isCollapsed={isCollapsed} - isDependent={isDependent} - isLoading={isLoading} - isSubForm={dialog === false} - key={resource?.cid} - preHeaderButtons={collapsibleButton} - resource={resource} - title={relationship.label} - onAdd={undefined} - onDeleted={ - collection.models.length <= 1 ? handleClose : undefined - } - onSaved={handleClose} - viewName={viewName} - /* - * Don't save the resource on save button click if it is a dependent - * resource - */ - onClose={handleClose} - /> - - ) : null} - {formType === 'formTable' ? ( - + {isAttachmentTable && ( + + )} + {specifyNetworkBadge} + {!isToOne && slider} + + )} isCollapsed={isCollapsed} + isDependent={isDependent} + isLoading={isLoading} + isSubForm={dialog === false} + key={resource?.cid} preHeaderButtons={collapsibleButton} - sortField={sortField} - viewName={viewName} - onAdd={ - isTaxonTreeDefItemTable - ? undefined - : (resources): void => { - if (!isInteraction) collection.add(resources); - handleAdd?.(resources); - } + resource={resource} + title={relationship.label} + onAdd={undefined} + onDeleted={ + collection.models.length <= 1 ? handleClose : undefined } + onSaved={handleClose} + viewName={viewName} + /* + * Don't save the resource on save button click if it is a dependent + * resource + */ onClose={handleClose} - onDelete={(resource, index): void => { - if (isAttachmentTable) { - handleWarning() - } else { - collection.remove(resource); - if (isCollapsed) handleExpand(); - handleDelete?.(index, 'minusButton'); - } - }} - onFetchMore={handleFetch} - /> - ) : null} - {dialogs} - {state.type === 'AddResourceState' && - typeof handleAdd === 'function' ? ( - setState({ type: 'MainState' })} - onDeleted={undefined} - onSaved={(): void => { - state.handleAdd([state.resource]); - setState({ type: 'MainState' }); - }} /> - ) : null} - {isWarningOpen && typeof handleRemove === 'function' && isAttachmentTable? - - : undefined - } - - )} - - - ); - } + + ) : null} + {formType === 'formTable' ? ( + { + if (!isInteraction) collection.add(resources); + handleAdd?.(resources); + } + } + onClose={handleClose} + onDelete={(resource, index): void => { + if (isAttachmentTable) { + handleWarning(); + } else { + collection.remove(resource); + if (isCollapsed) handleExpand(); + handleDelete?.(index, 'minusButton'); + } + }} + onFetchMore={handleFetch} + /> + ) : null} + {dialogs} + {state.type === 'AddResourceState' && + typeof handleAdd === 'function' ? ( + setState({ type: 'MainState' })} + onDeleted={undefined} + onSaved={(): void => { + state.handleAdd([state.resource]); + setState({ type: 'MainState' }); + }} + /> + ) : null} + {isWarningOpen && + typeof handleRemove === 'function' && + isAttachmentTable ? ( + + ) : undefined} + + )} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx index e2428ab6ff5..54b60311193 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialog.tsx @@ -176,7 +176,8 @@ export function PrepDialog({ const items = filterArray( preparations.map((preparation, index) => { - if (selected[index] === 0 || Number.isNaN(selected[index])) return undefined; + if (selected[index] === 0 || Number.isNaN(selected[index])) + return undefined; const result = new itemTable.Resource(); result.set( diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialogRow.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialogRow.tsx index d9cb47f06c8..32ea7169b79 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialogRow.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/PrepDialogRow.tsx @@ -82,11 +82,13 @@ export function PrepDialogRow({ {preparation.taxon ? ( - + {localized(preparation.taxon)} ) : ( - {interactionsText.notAvailable()} + {interactionsText.notAvailable()} )} {preparation.prepType} diff --git a/specifyweb/frontend/js_src/lib/components/Reports/index.tsx b/specifyweb/frontend/js_src/lib/components/Reports/index.tsx index a2e6d3c879b..971a3557b20 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/index.tsx @@ -248,9 +248,9 @@ function ReportRow({ - + diff --git a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx index f0c77508b93..0b7ae8fb7f9 100644 --- a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx @@ -93,7 +93,7 @@ export function SearchDialog( }} /> ) : ( - + ); } @@ -186,7 +186,7 @@ function SearchForm({ onClose: handleClose, onUseQueryBuilder: handleUseQueryBuilder, onAdd: handleAdd, - multiple + multiple, }: { readonly forceCollection: number | undefined; readonly extraFilters: RA> | undefined; @@ -198,7 +198,7 @@ function SearchForm({ readonly onAdd?: | ((resources: RA>) => void) | undefined; - readonly multiple?: boolean + readonly multiple?: boolean; }): JSX.Element | null { const templateResource = React.useMemo( () => @@ -242,11 +242,13 @@ function SearchForm({ {queryText.queryBuilder()} - {multiple === true && } + {multiple === true && ( + + )} {commonText.search()} diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index ea0db3bde7b..d99287b0699 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -683,9 +683,9 @@ export const attachmentsText = createDictionary({ 'en-us': 'Download all found attachments', }, deleteAttachmentWarning: { - 'en-us': 'Are you sure you want to delete this attachment?' + 'en-us': 'Are you sure you want to delete this attachment?', }, attachmentDelition: { - 'en-us': 'Attachment deletion' - } + 'en-us': 'Attachment deletion', + }, } as const); diff --git a/specifyweb/frontend/js_src/lib/localization/interactions.ts b/specifyweb/frontend/js_src/lib/localization/interactions.ts index 34160d9b5c0..83d75f4c79e 100644 --- a/specifyweb/frontend/js_src/lib/localization/interactions.ts +++ b/specifyweb/frontend/js_src/lib/localization/interactions.ts @@ -388,6 +388,6 @@ export const interactionsText = createDictionary({ 'uk-ua': 'Продовжуйте без підготовки', }, notAvailable: { - 'en-us': 'Not available' - } + 'en-us': 'Not available', + }, } as const); From cc1f85bd32a185542fd8d230f1a9e178b2796b31 Mon Sep 17 00:00:00 2001 From: Sharad S <16229739+sharadsw@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:49:57 -0400 Subject: [PATCH 37/43] Enable nested to-many in Workbench (#6216) * Enable nested to-many in Workbench * Update test * Add upload plan changes * Update tests * Lint code with ESLint and Prettier Triggered by 82033cd99c8fd333c069657c462df7ac98fe9564 on branch refs/heads/issue-2331 * Fix tests * Lint code with ESLint and Prettier Triggered by f0822bf96521b36566cb048ad22e77827189bbd0 on branch refs/heads/issue-2331 * Lint code with ESLint and Prettier Triggered by f27581be3c61aa9de4e02cbbe8215c2fe2a664e4 on branch refs/heads/issue-2331 --- .../lib/components/BatchEdit/MissingRanks.tsx | 2 +- .../WbPlanView/__tests__/navigator.test.ts | 597 +++++++++--------- .../components/WbPlanView/navigatorSpecs.ts | 2 +- .../WbPlanView/uploadPlanBuilder.ts | 13 +- .../components/WbPlanView/uploadPlanParser.ts | 40 +- .../lib/tests/fixtures/uploadplan.1.json | 24 +- 6 files changed, 347 insertions(+), 331 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx index e73d0c2a7fc..2fbd1182e36 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/MissingRanks.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; -import type { RA,RR } from '../../utils/types'; +import type { RA, RR } from '../../utils/types'; import { H2, H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index b9e32ed6b84..196d7d4214d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -20,8 +20,8 @@ theories(getMappingLineData, [ ], out: [ { - defaultValue: 'determinations', customSelectSubtype: 'simple', + defaultValue: 'determinations', selectLabel: localized('Collection Object'), fieldsData: { catalogNumber: { @@ -74,15 +74,6 @@ theories(getMappingLineData, [ isDefault: false, isRelationship: false, }, - leftSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', - }, altCatalogNumber: { optionLabel: 'Prev/Exch #', isEnabled: true, @@ -115,15 +106,6 @@ theories(getMappingLineData, [ isDefault: false, isRelationship: false, }, - rightSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', - tableName: 'CollectionRelationship', - }, fieldNumber: { optionLabel: 'Voucher', isEnabled: true, @@ -168,6 +150,24 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'CollectionObjectAttribute', }, + collection: { + optionLabel: 'Collection', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'Collection', + }, + collectionObjectAttachments: { + optionLabel: 'Collection Object Attachments', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionObjectAttachment', + }, collectionObjectCitations: { optionLabel: 'Collection Object Citations', isEnabled: true, @@ -213,23 +213,14 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'CollectingEvent', }, - collection: { - isDefault: false, + leftSideRels: { + optionLabel: 'Left Side Rels', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Collection', - tableName: 'Collection', - }, - collectionObjectAttachments: { - isDefault: false, - isEnabled: true, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', + tableName: 'CollectionRelationship', }, preparations: { optionLabel: 'Preparations', @@ -240,6 +231,15 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'Preparation', }, + rightSideRels: { + optionLabel: 'Right Side Rels', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionRelationship', + }, voucherRelationships: { optionLabel: 'Voucher Relationships', isEnabled: true, @@ -253,8 +253,8 @@ theories(getMappingLineData, [ tableName: 'CollectionObject', }, { - defaultValue: '#1', customSelectSubtype: 'toMany', + defaultValue: '#1', selectLabel: localized('Determination'), fieldsData: { '#1': { @@ -273,8 +273,8 @@ theories(getMappingLineData, [ tableName: 'Determination', }, { - defaultValue: 'taxon', customSelectSubtype: 'simple', + defaultValue: 'taxon', selectLabel: localized('Determination'), fieldsData: { determinedDate: { @@ -310,6 +310,15 @@ theories(getMappingLineData, [ isRelationship: true, tableName: 'Agent', }, + determiners: { + optionLabel: 'Determiners', + isEnabled: true, + isRequired: false, + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'Determiner', + }, taxon: { optionLabel: 'Taxon', isEnabled: true, @@ -323,8 +332,8 @@ theories(getMappingLineData, [ tableName: 'Determination', }, { - defaultValue: '$Family', customSelectSubtype: 'tree', + defaultValue: '$Family', selectLabel: localized('Taxon'), fieldsData: { $Kingdom: { @@ -391,8 +400,8 @@ theories(getMappingLineData, [ tableName: 'Taxon', }, { - defaultValue: 'name', customSelectSubtype: 'simple', + defaultValue: 'name', selectLabel: localized('Taxon'), fieldsData: { author: { @@ -462,13 +471,14 @@ theories(getMappingLineData, [ { customSelectSubtype: 'simple', defaultValue: 'determinations', + selectLabel: localized('Collection Object'), fieldsData: { '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, optionLabel: '(formatted)', tableName: 'CollectionObject', + isRelationship: false, + isDefault: false, + isEnabled: true, }, absoluteAges: { isDefault: false, @@ -479,71 +489,142 @@ theories(getMappingLineData, [ optionLabel: 'Absolute Ages', tableName: 'AbsoluteAge', }, - accession: { - isDefault: false, + catalogNumber: { + optionLabel: 'Cat #', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Accession #', - tableName: 'Accession', - }, - altCatalogNumber: { + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'catalogedDate-fullDate': { + optionLabel: 'Cat Date', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Prev/Exch #', }, - catalogNumber: { - isDefault: false, + 'catalogedDate-day': { + optionLabel: 'Cat Date (Day)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Cat #', }, - 'catalogedDate-day': { + 'catalogedDate-month': { + optionLabel: 'Cat Date (Month)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'catalogedDate-year': { + optionLabel: 'Cat Date (Year)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + reservedText: { + optionLabel: 'CT Scan', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Day)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-fullDate': { + 'timestampModified-fullDate': { + optionLabel: 'Date Edited', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + 'timestampModified-day': { + optionLabel: 'Date Edited (Day)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + 'timestampModified-month': { + optionLabel: 'Date Edited (Month)', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-month': { + 'timestampModified-year': { + optionLabel: 'Date Edited (Year)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + guid: { + optionLabel: 'GUID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + altCatalogNumber: { + optionLabel: 'Prev/Exch #', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Month)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - 'catalogedDate-year': { + projectNumber: { + optionLabel: 'Project Number', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + remarks: { + optionLabel: 'Remarks', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + reservedText2: { + optionLabel: 'Reserved Text2', + isEnabled: true, isRequired: false, - optionLabel: 'Cat Date (Year)', + isHidden: false, + isDefault: false, + isRelationship: false, }, - cataloger: { + fieldNumber: { + optionLabel: 'Voucher', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + accession: { + optionLabel: 'Accession #', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Cataloger', - tableName: 'Agent', + tableName: 'Accession', }, cojo: { isDefault: false, @@ -554,49 +635,49 @@ theories(getMappingLineData, [ optionLabel: 'Cojo', tableName: 'CollectionObjectGroupJoin', }, - collectingEvent: { - isDefault: false, + cataloger: { + optionLabel: 'Cataloger', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, + tableName: 'Agent', + }, + collectionObjectAttribute: { + optionLabel: 'Col Obj Attribute', + isEnabled: true, isRequired: false, - optionLabel: 'Field No: Locality', - tableName: 'CollectingEvent', + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionObjectAttribute', }, collection: { - isDefault: false, + optionLabel: 'Collection', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection', tableName: 'Collection', }, collectionObjectAttachments: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', - }, - collectionObjectAttribute: { - isDefault: false, isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Col Obj Attribute', - tableName: 'CollectionObjectAttribute', + tableName: 'CollectionObjectAttachment', }, collectionObjectCitations: { - isDefault: false, + optionLabel: 'Collection Object Citations', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Citations', tableName: 'CollectionObjectCitation', }, collectionObjectType: { @@ -609,73 +690,40 @@ theories(getMappingLineData, [ tableName: 'CollectionObjectType', }, determinations: { - isDefault: true, + optionLabel: 'Determinations', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: true, isRelationship: true, - isRequired: false, - optionLabel: 'Determinations', tableName: 'Determination', }, dnaSequences: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, optionLabel: 'DNA Sequences', - tableName: 'DNASequence', - }, - fieldNumber: { - isDefault: false, isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Voucher', - }, - guid: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'GUID', - }, - leftSideRels: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', + tableName: 'DNASequence', }, modifiedByAgent: { - isDefault: false, + optionLabel: 'Edited By', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Edited By', tableName: 'Agent', }, - preparations: { - isDefault: false, + collectingEvent: { + optionLabel: 'Field No: Locality', isEnabled: true, - isHidden: false, - isRelationship: true, isRequired: false, - optionLabel: 'Preparations', - tableName: 'Preparation', - }, - projectNumber: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Project Number', + isDefault: false, + isRelationship: true, + tableName: 'CollectingEvent', }, relativeAges: { isDefault: false, @@ -686,301 +734,263 @@ theories(getMappingLineData, [ optionLabel: 'Relative Ages', tableName: 'RelativeAge', }, - remarks: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Remarks', - }, - reservedText: { - isDefault: false, + leftSideRels: { + optionLabel: 'Left Side Rels', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'CT Scan', - }, - reservedText2: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Reserved Text2', - }, - rightSideRels: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', tableName: 'CollectionRelationship', }, - 'timestampModified-day': { - isDefault: false, + preparations: { + optionLabel: 'Preparations', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Date Edited (Day)', - }, - 'timestampModified-fullDate': { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited', - }, - 'timestampModified-month': { isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited (Month)', + isRelationship: true, + tableName: 'Preparation', }, - 'timestampModified-year': { - isDefault: false, + rightSideRels: { + optionLabel: 'Right Side Rels', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'Date Edited (Year)', + isHidden: false, + isDefault: false, + isRelationship: true, + tableName: 'CollectionRelationship', }, voucherRelationships: { - isDefault: false, + optionLabel: 'Voucher Relationships', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Voucher Relationships', tableName: 'VoucherRelationship', }, }, - selectLabel: localized('Collection Object'), tableName: 'CollectionObject', }, { customSelectSubtype: 'simple', defaultValue: 'taxon', + selectLabel: localized('Determination'), fieldsData: { '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, optionLabel: '(aggregated)', tableName: 'Determination', - }, - 'determinedDate-day': { + isRelationship: false, isDefault: false, isEnabled: true, + }, + isCurrent: { + optionLabel: 'Current', + isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Date (Day)', }, 'determinedDate-fullDate': { - isDefault: false, + optionLabel: 'Date', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + 'determinedDate-day': { + optionLabel: 'Date (Day)', + isEnabled: true, isRequired: false, - optionLabel: 'Date', + isHidden: false, + isDefault: false, + isRelationship: false, }, 'determinedDate-month': { - isDefault: false, + optionLabel: 'Date (Month)', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Date (Month)', }, 'determinedDate-year': { + optionLabel: 'Date (Year)', + isEnabled: true, + isRequired: false, + isHidden: false, isDefault: false, + isRelationship: false, + }, + guid: { + optionLabel: 'GUID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, + }, + typeStatusName: { + optionLabel: 'Type Status', + isEnabled: true, isRequired: false, - optionLabel: 'Date (Year)', + isHidden: false, + isDefault: false, + isRelationship: false, }, determiner: { - isDefault: false, + optionLabel: 'Determiner', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Determiner', tableName: 'Agent', }, determiners: { - isDefault: false, + optionLabel: 'Determiners', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: true, - isRequired: false, - optionLabel: 'Determiners', tableName: 'Determiner', }, - guid: { - isDefault: false, + preferredTaxon: { + optionLabel: 'Preferred Taxon', isEnabled: true, - isHidden: false, - isRelationship: false, isRequired: false, - optionLabel: 'GUID', - }, - isCurrent: { - isDefault: false, - isEnabled: true, isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Current', - }, - preferredTaxon: { isDefault: false, - isEnabled: true, - isHidden: false, isRelationship: true, - isRequired: false, - optionLabel: 'Preferred Taxon', tableName: 'Taxon', }, taxon: { - isDefault: true, + optionLabel: 'Taxon', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: true, isRelationship: true, - isRequired: false, - optionLabel: 'Taxon', tableName: 'Taxon', }, - typeStatusName: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Type Status', - }, }, - selectLabel: localized('Determination'), tableName: 'Determination', }, { customSelectSubtype: 'tree', defaultValue: '$Family', + selectLabel: localized('Taxon'), fieldsData: { '$-any': { + optionLabel: '(any rank)', + isRelationship: true, isDefault: false, isEnabled: true, - isRelationship: true, - optionLabel: '(any rank)', tableName: 'Taxon', }, - $Class: { - isDefault: false, + $Kingdom: { + optionLabel: 'Kingdom', isRelationship: true, - optionLabel: 'Class', + isDefault: false, tableName: 'Taxon', }, - $Family: { - isDefault: true, + $Phylum: { + optionLabel: 'Phylum', isRelationship: true, - optionLabel: 'Family', - tableName: 'Taxon', - }, - $Genus: { isDefault: false, - isRelationship: true, - optionLabel: 'Genus', tableName: 'Taxon', }, - $Kingdom: { - isDefault: false, + $Class: { + optionLabel: 'Class', isRelationship: true, - optionLabel: 'Kingdom', + isDefault: false, tableName: 'Taxon', }, $Order: { - isDefault: false, - isRelationship: true, optionLabel: 'Order', - tableName: 'Taxon', - }, - $Phylum: { - isDefault: false, isRelationship: true, - optionLabel: 'Phylum', + isDefault: false, tableName: 'Taxon', }, - $Species: { - isDefault: false, + $Family: { + optionLabel: 'Family', isRelationship: true, - optionLabel: 'Species', + isDefault: true, tableName: 'Taxon', }, $Subfamily: { + optionLabel: 'Subfamily', + isRelationship: true, isDefault: false, + tableName: 'Taxon', + }, + $Genus: { + optionLabel: 'Genus', isRelationship: true, - optionLabel: 'Subfamily', + isDefault: false, tableName: 'Taxon', }, $Subgenus: { + optionLabel: 'Subgenus', + isRelationship: true, isDefault: false, + tableName: 'Taxon', + }, + $Species: { + optionLabel: 'Species', isRelationship: true, - optionLabel: 'Subgenus', + isDefault: false, tableName: 'Taxon', }, $Subspecies: { - isDefault: false, - isRelationship: true, optionLabel: 'Subspecies', + isRelationship: true, + isDefault: false, tableName: 'Taxon', }, }, - selectLabel: localized('Taxon'), tableName: 'Taxon', }, { customSelectSubtype: 'simple', defaultValue: 'name', + selectLabel: localized('Taxon'), fieldsData: { author: { - isDefault: false, + optionLabel: 'Author', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Author', }, fullName: { - isDefault: false, + optionLabel: 'Full Name', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Full Name', }, commonName: { - isDefault: false, + optionLabel: 'Common Name', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Common Name', }, guid: { - isDefault: false, + optionLabel: 'GUID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'GUID', }, isAccepted: { isDefault: false, @@ -991,47 +1001,46 @@ theories(getMappingLineData, [ optionLabel: 'Is Preferred', }, isHybrid: { - isDefault: false, + optionLabel: 'Is Hybrid', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Is Hybrid', }, name: { - isDefault: true, + optionLabel: 'Name', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: true, isRelationship: false, - isRequired: false, - optionLabel: 'Name', }, rankId: { - isDefault: false, + optionLabel: 'Rank ID', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Rank ID', }, remarks: { - isDefault: false, + optionLabel: 'Remarks', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Remarks', }, source: { - isDefault: false, + optionLabel: 'Source', isEnabled: true, + isRequired: false, isHidden: false, + isDefault: false, isRelationship: false, - isRequired: false, - optionLabel: 'Source', }, }, - selectLabel: localized('Taxon'), tableName: 'Taxon', }, ], diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index f15346f1760..5b3e098584f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -56,7 +56,7 @@ const wbPlanView: NavigatorSpec = { * Hide nested -to-many relationships as they are not * supported by the WorkBench */ - allowNestedToMany: false, + allowNestedToMany: true, ensurePermission: () => userPreferences.get('workBench', 'wbPlanView', 'showNoAccessTables') ? 'create' diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts index 8be421c3cd2..636526249f6 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts @@ -1,5 +1,5 @@ import type { IR, RA, RR } from '../../utils/types'; -import { group, removeKey, split, toLowerCase } from '../../utils/utils'; +import { group, split, toLowerCase } from '../../utils/utils'; import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; @@ -143,13 +143,10 @@ function toUploadTable( [ fieldName.toLowerCase(), indexMappings(lines).map(([_index, lines]) => - removeKey( - toUploadTable( - table.strictGetRelationship(fieldName).relatedTable, - lines, - mustMatchPreferences - ), - 'toMany' + toUploadTable( + table.strictGetRelationship(fieldName).relatedTable, + lines, + mustMatchPreferences ) ), ] as const diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts index 5d354807723..d05fc192601 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts @@ -1,5 +1,5 @@ -import type { IR, RA, RR } from '../../utils/types'; -import type { AnyTree } from '../DataModel/helperTypes'; +import type { IR, PartialBy, RA, RR } from '../../utils/types'; +import { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; @@ -24,7 +24,11 @@ export type ColumnDefinition = | string | (ColumnOptions & { readonly column: string }); -export type NestedUploadTable = Omit; +/* + * NOTE: This comment was added after workbench supports nested-to-manys. + * Type is made Partial to not chock on legacy upload plans + */ +export type NestedUploadTable = PartialBy; export type UploadTable = { readonly wbcols: IR; @@ -147,23 +151,21 @@ const parseUploadTable = ( [...mappingPath, table.strictGetRelationship(relationshipName).name] ) ), - ...('toMany' in uploadPlan - ? Object.entries(uploadPlan.toMany).flatMap( - ([relationshipName, mappings]) => - Object.values(mappings).flatMap((mapping, index) => - parseUploadTable( - table.strictGetRelationship(relationshipName).relatedTable, - mapping, - makeMustMatch, - [ - ...mappingPath, - table.strictGetRelationship(relationshipName).name, - formatToManyIndex(index + 1), - ] - ) - ) + ...Object.entries(uploadPlan.toMany ?? []).flatMap( + ([relationshipName, mappings]) => + Object.values(mappings).flatMap((mapping, index) => + parseUploadTable( + table.strictGetRelationship(relationshipName).relatedTable, + mapping, + makeMustMatch, + [ + ...mappingPath, + table.strictGetRelationship(relationshipName).name, + formatToManyIndex(index + 1), + ] + ) ) - : []), + ), ]; function parseUploadTableTypes( diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json index f0cc682997a..ea0336746b4 100644 --- a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json +++ b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json @@ -160,7 +160,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -179,7 +180,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -198,7 +200,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -217,7 +220,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -246,7 +250,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -328,7 +333,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": { @@ -346,7 +352,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ], "preparations": [ @@ -364,7 +371,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } From a81c95f08428b54dd2dd5a20248722cb5cce901a Mon Sep 17 00:00:00 2001 From: Sharad S <16229739+sharadsw@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:53:37 +0000 Subject: [PATCH 38/43] Lint code with ESLint and Prettier Triggered by cc1f85bd32a185542fd8d230f1a9e178b2796b31 on branch refs/heads/issue-6127 --- .../js_src/lib/components/WbPlanView/uploadPlanParser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts index d05fc192601..6126608713a 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts @@ -1,5 +1,5 @@ import type { IR, PartialBy, RA, RR } from '../../utils/types'; -import { AnyTree } from '../DataModel/helperTypes'; +import type { AnyTree } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; From 1e393f0707468ac68ff2da2ebeba11758a7ded9d Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 21 Apr 2025 12:25:42 -0400 Subject: [PATCH 39/43] Check for lowercase tree table names when rewriting tree rank row plan --- specifyweb/stored_queries/batch_edit_query_rewrites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/batch_edit_query_rewrites.py b/specifyweb/stored_queries/batch_edit_query_rewrites.py index 038f762b622..1ce22a79bb2 100644 --- a/specifyweb/stored_queries/batch_edit_query_rewrites.py +++ b/specifyweb/stored_queries/batch_edit_query_rewrites.py @@ -25,7 +25,7 @@ def _track_observed_ranks( relname, current = _current if ( current.tree_rank is None - or (current.tree_rank.relatedModelName != table_name) + or (current.tree_rank.relatedModelName.lower() != table_name.lower()) or current.tree_rank.treedef_id is not None or (current.tree_rank.name not in all_current_ranks) ): From e593bc00206d962955d9a730568c344376587c27 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Wed, 23 Apr 2025 13:40:26 -0400 Subject: [PATCH 40/43] Handle None rank --- specifyweb/workbench/upload/treerecord.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 4f623dcab52..e1cca803cfc 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -190,6 +190,8 @@ def apply_batch_edit_pack( return self # batch-edit considers ranks as self-relationships, and are trivially stored in to-one rank_from_pack = batch_edit_pack.get("to_one", {}) + if rank_from_pack is None: + rank_from_pack = {} return self._replace( batch_edit_pack={ rank: pack["self"] for (rank, pack) in rank_from_pack.items() From 82222469cfdf7406dcfdeefeec7f9aa3afdc9fb1 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 28 Apr 2025 11:39:15 -0400 Subject: [PATCH 41/43] Fix tree column order --- specifyweb/specify/func.py | 4 ++++ specifyweb/stored_queries/batch_edit.py | 15 ++++++++++++--- .../stored_queries/batch_edit_query_rewrites.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 7b61dc3838f..497d0c42dcd 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -18,6 +18,10 @@ def maybe(value: Optional[I], callback: Callable[[I], O]): @staticmethod def sort_by_key(to_sort: Dict[I, O], reverse=False) -> List[Tuple[I, O]]: return sorted(to_sort.items(), key=lambda t: t[0], reverse=reverse) + + @staticmethod + def obj_to_list(obj: Dict[I, O]) -> List[Tuple[I, O]]: + return [(key, val) for key, val in obj.items()] @staticmethod def make_ors(eprns: List[Q]) -> Q: diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index ce18a2cfa7a..bfdddbcf949 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -321,6 +321,10 @@ def _index( # to make things simpler, returns the QueryFields along with indexed plan, which are expected to be used together def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: + intermediary_to_tree = any( + rowmap.tree_rank is not None for _, rowmap in self.to_one.items() + ) + next_index = len(self.columns) + start_index # For optimization, and sanity, we remove the field from columns, as they are now completely redundant (we always know what they are using the id) _columns = [ @@ -339,7 +343,7 @@ def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: next_index, _to_one, to_one_fields = reduce( RowPlanMap._index, # makes the order deterministic, would be funny otherwise - Func.sort_by_key(self.to_one), + Func.obj_to_list(self.to_one) if intermediary_to_tree else Func.sort_by_key(self.to_one), init(next_index), ) next_index, _to_many, to_many_fields = reduce( @@ -934,7 +938,7 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): to_one_headers, to_one_upload_tables = reduce( _to_one_reducer, - Func.sort_by_key(self.to_one), + Func.obj_to_list(self.to_one) if intermediary_to_tree else Func.sort_by_key(self.to_one), (headers_init, _to_one_table), ) @@ -1169,7 +1173,12 @@ def _get_orig_column(string_id: str): # We would have arbitarily sorted the columns, so our columns will not be correct. # Rather than sifting the data, we just add a default visual order. - visual_order = Func.first(sorted(headers_enumerated, key=lambda tup: tup[1][0])) + visual_order = Func.first( + sorted( + headers_enumerated, + key=lambda tup: tup[1][0][1] if len(tup[1][0]) > 1 else tup[1][0] + ) + ) headers = Func.second(key_and_headers) diff --git a/specifyweb/stored_queries/batch_edit_query_rewrites.py b/specifyweb/stored_queries/batch_edit_query_rewrites.py index 1ce22a79bb2..3338fbc1a07 100644 --- a/specifyweb/stored_queries/batch_edit_query_rewrites.py +++ b/specifyweb/stored_queries/batch_edit_query_rewrites.py @@ -154,8 +154,8 @@ def _rewrite_multiple_trees( new_rels = [ *new_rels, # NOTE: The order between finals_rels_created and rels_created does not matter - *final_rels_created, *rels_created, + *final_rels_created ] # Now, we'have done the iteration over all the possible treees and have made the corresponding tree query ranks in the columns From afe7be1a6d23120632bad331793f9bc5da37c288 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 28 Apr 2025 14:29:32 -0400 Subject: [PATCH 42/43] Fix tests --- specifyweb/stored_queries/batch_edit.py | 7 +---- .../tests/static/co_query_row_plan.py | 8 ++--- .../stored_queries/tests/test_batch_edit.py | 30 ++++++++++--------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index bfdddbcf949..8bec157882e 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1173,12 +1173,7 @@ def _get_orig_column(string_id: str): # We would have arbitarily sorted the columns, so our columns will not be correct. # Rather than sifting the data, we just add a default visual order. - visual_order = Func.first( - sorted( - headers_enumerated, - key=lambda tup: tup[1][0][1] if len(tup[1][0]) > 1 else tup[1][0] - ) - ) + visual_order = Func.first(headers_enumerated) headers = Func.second(key_and_headers) diff --git a/specifyweb/stored_queries/tests/static/co_query_row_plan.py b/specifyweb/stored_queries/tests/static/co_query_row_plan.py index fd83e149aaa..752db734da3 100644 --- a/specifyweb/stored_queries/tests/static/co_query_row_plan.py +++ b/specifyweb/stored_queries/tests/static/co_query_row_plan.py @@ -121,7 +121,7 @@ to_many={}, is_naive=True, ), - "County": RowPlanMap( + "Province": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack( field=None, idx=42, value=None @@ -142,7 +142,7 @@ to_many={}, is_naive=True, ), - "Province": RowPlanMap( + "County": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack( field=None, idx=45, value=None @@ -193,7 +193,7 @@ ), columns=[], to_one={ - "Genus": RowPlanMap( + "Subspecies": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack(field=None, idx=53, value=None), order=BatchEditFieldPack( @@ -227,7 +227,7 @@ to_many={}, is_naive=True, ), - "Subspecies": RowPlanMap( + "Genus": RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack(field=None, idx=59, value=None), order=BatchEditFieldPack( diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index e3b34caee42..5fa4beceff3 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -86,6 +86,7 @@ def _create(model, kwargs): ) def test_query_construction(self): + self.maxDiff = None query = json.load(open("specifyweb/stored_queries/tests/static/co_query.json")) query_fields = fields_from_json(query["fields"]) visible_fields = [field for field in query_fields if field.display] @@ -154,9 +155,9 @@ def test_basic_run(self): "CollectionObject catalogNumber", "CollectionObject integer1", "Agent (formatted)", + "CollectingEvent (formatted)", "Agent firstName", "Agent lastName", - "CollectingEvent (formatted)", "Locality localityName", ], ) @@ -306,6 +307,7 @@ def test_basic_run(self): @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_duplicates_flattened(self): + self.maxDiff = None base_table = "collectionobject" query_paths = [ ["catalognumber"], @@ -552,12 +554,6 @@ def test_duplicates_flattened(self): "CollectionObject catalogNumber", "CollectionObject integer1", "Agent (formatted)", - "Determination integer1", - "Determination integer1 #2", - "Determination integer1 #3", - "Determination remarks", - "Determination remarks #2", - "Determination remarks #3", "Agent firstName", "Agent lastName", "AgentSpecialty specialtyName", @@ -565,21 +561,27 @@ def test_duplicates_flattened(self): "AgentSpecialty specialtyName #3", "AgentSpecialty specialtyName #4", "Collector remarks", - "Collector remarks #2", - "Collector remarks #3", - "Collector remarks #4", - "Collector remarks #5", - "Collector remarks #6", - "Collector remarks #7", - "Collector remarks #8", "CollectingEvent stationFieldNumber", + "Collector remarks #2", "CollectingEvent stationFieldNumber #2", + "Collector remarks #3", "CollectingEvent stationFieldNumber #3", + "Collector remarks #4", "CollectingEvent stationFieldNumber #4", + "Collector remarks #5", "CollectingEvent stationFieldNumber #5", + "Collector remarks #6", "CollectingEvent stationFieldNumber #6", + "Collector remarks #7", "CollectingEvent stationFieldNumber #7", + "Collector remarks #8", "CollectingEvent stationFieldNumber #8", + "Determination integer1", + "Determination remarks", + "Determination integer1 #2", + "Determination remarks #2", + "Determination integer1 #3", + "Determination remarks #3", ], ) From b273a60250e124aace0d2b6768fc38daa995dcf4 Mon Sep 17 00:00:00 2001 From: Sharad S Date: Mon, 28 Apr 2025 18:30:22 -0400 Subject: [PATCH 43/43] Revert back to sort columns --- specifyweb/stored_queries/batch_edit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 8bec157882e..84f5eb5e52d 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -938,7 +938,7 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): to_one_headers, to_one_upload_tables = reduce( _to_one_reducer, - Func.obj_to_list(self.to_one) if intermediary_to_tree else Func.sort_by_key(self.to_one), + Func.sort_by_key(self.to_one), (headers_init, _to_one_table), )