From c88a51afbb90155d2506096cfa895caabbe546fa Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 16:17:20 -0400 Subject: [PATCH 01/15] Fix tree rank picklist validation after async data loads --- .../lib/components/PickLists/TreeLevelPickList.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 7da5712d9db..84ac542021e 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -142,8 +142,12 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { const resource = toTreeTable(props.resource); const definitionItem = resource?.get('definitionItem'); + const hasDefaultDefinitionItem = + typeof props.defaultValue === 'string' && props.defaultValue.length > 0; const newDefinitionItem = - props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; + (hasDefaultDefinitionItem + ? props.defaultValue + : items?.slice(-1)[0]?.value) ?? ''; const isDifferentDefinitionItem = newDefinitionItem !== (definitionItem ?? ''); @@ -159,7 +163,9 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { invalidDefinitionItem ) { resource?.set('definitionItem', newDefinitionItem); - return void resource?.businessRuleManager?.checkField('parent'); + resource?.businessRuleManager?.checkField('definitionItem'); + resource?.businessRuleManager?.checkField('parent'); + return undefined; } return undefined; }, [items]); From 9b681f8b00ffcccdef1af8a6c9328608dd0b178a Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 17:00:04 -0400 Subject: [PATCH 02/15] Normalize tree rank defaults against loaded picklist items --- .../lib/components/PickLists/TreeLevelPickList.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 84ac542021e..1fe4ad7466b 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -142,12 +142,15 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { const resource = toTreeTable(props.resource); const definitionItem = resource?.get('definitionItem'); - const hasDefaultDefinitionItem = - typeof props.defaultValue === 'string' && props.defaultValue.length > 0; + const defaultDefinitionItem = + typeof props.defaultValue === 'string' && props.defaultValue.length > 0 + ? items?.find( + ({ value, title }) => + value === props.defaultValue || title === props.defaultValue + )?.value ?? props.defaultValue + : undefined; const newDefinitionItem = - (hasDefaultDefinitionItem - ? props.defaultValue - : items?.slice(-1)[0]?.value) ?? ''; + defaultDefinitionItem ?? items?.slice(-1)[0]?.value ?? ''; const isDifferentDefinitionItem = newDefinitionItem !== (definitionItem ?? ''); From bbda23e99f053137dbe50c6009fa5328fecf5a24 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 18:12:34 -0400 Subject: [PATCH 03/15] Refactor definition item handling in TreeLevelPickList --- .../PickLists/TreeLevelPickList.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 1fe4ad7466b..b1b2fd2334e 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -140,24 +140,29 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { React.useEffect(() => { if (props.resource === undefined) return undefined; const resource = toTreeTable(props.resource); - const definitionItem = resource?.get('definitionItem'); + const rawDefinitionItem = resource?.get('definitionItem'); + const definitionItem = typeof rawDefinitionItem === 'string' + ? rawDefinitionItem + : typeof rawDefinitionItem === 'object' && rawDefinitionItem !== null + ? rawDefinitionItem?.resource_uri ?? '' + : ''; - const defaultDefinitionItem = - typeof props.defaultValue === 'string' && props.defaultValue.length > 0 - ? items?.find( - ({ value, title }) => - value === props.defaultValue || title === props.defaultValue - )?.value ?? props.defaultValue - : undefined; const newDefinitionItem = - defaultDefinitionItem ?? items?.slice(-1)[0]?.value ?? ''; + props.defaultValue ?? + definitionItem ?? + items?.slice(-1)[0]?.value ?? + ''; const isDifferentDefinitionItem = - newDefinitionItem !== (definitionItem ?? ''); + (typeof rawDefinitionItem === 'object' && rawDefinitionItem !== null) || + newDefinitionItem !== definitionItem; + const itemValues = items?.map(({ value }) => value); + const isKnownDefinitionItem = + itemValues === undefined || itemValues.includes(definitionItem); const invalidDefinitionItem = - typeof definitionItem !== 'string' || - (!(items?.map(({ value }) => value).includes(definitionItem) ?? true) && + definitionItem === '' || + (!isKnownDefinitionItem && !Object.keys(resource?.changed ?? {}).includes('definitionitem')); if ( @@ -166,9 +171,7 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { invalidDefinitionItem ) { resource?.set('definitionItem', newDefinitionItem); - resource?.businessRuleManager?.checkField('definitionItem'); - resource?.businessRuleManager?.checkField('parent'); - return undefined; + return void resource?.businessRuleManager?.checkField('parent'); } return undefined; }, [items]); From 1bac75f9e067eb3037021f7bb2fa6c1631581e7e Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 18:19:19 -0400 Subject: [PATCH 04/15] treerank validation TS guard --- .../lib/components/PickLists/TreeLevelPickList.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index b1b2fd2334e..72c084ef0f2 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -141,11 +141,14 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { if (props.resource === undefined) return undefined; const resource = toTreeTable(props.resource); const rawDefinitionItem = resource?.get('definitionItem'); - const definitionItem = typeof rawDefinitionItem === 'string' - ? rawDefinitionItem - : typeof rawDefinitionItem === 'object' && rawDefinitionItem !== null - ? rawDefinitionItem?.resource_uri ?? '' - : ''; + const definitionItem = + typeof rawDefinitionItem === 'string' + ? rawDefinitionItem + : typeof rawDefinitionItem === 'object' && + rawDefinitionItem !== null && + 'resource_uri' in rawDefinitionItem + ? (rawDefinitionItem as { resource_uri?: string }).resource_uri ?? '' + : ''; const newDefinitionItem = props.defaultValue ?? From b3c04e4bb593cd83acc210597bdff206a6cab856 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 22:23:57 +0000 Subject: [PATCH 05/15] Lint code with ESLint and Prettier Triggered by 1bac75f9e067eb3037021f7bb2fa6c1631581e7e on branch refs/heads/issue-7511 --- .../js_src/lib/components/PickLists/TreeLevelPickList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 72c084ef0f2..1550ff635d1 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -147,7 +147,7 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { : typeof rawDefinitionItem === 'object' && rawDefinitionItem !== null && 'resource_uri' in rawDefinitionItem - ? (rawDefinitionItem as { resource_uri?: string }).resource_uri ?? '' + ? (rawDefinitionItem as { readonly resource_uri?: string }).resource_uri ?? '' : ''; const newDefinitionItem = From bfc5c0ddcf81f1369b78381556fd84b454536c9e Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 18:54:35 -0400 Subject: [PATCH 06/15] Added check to ensure items is loaded --- .../PickLists/TreeLevelPickList.tsx | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 1550ff635d1..eeb4745a66c 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -140,32 +140,18 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { React.useEffect(() => { if (props.resource === undefined) return undefined; const resource = toTreeTable(props.resource); - const rawDefinitionItem = resource?.get('definitionItem'); - const definitionItem = - typeof rawDefinitionItem === 'string' - ? rawDefinitionItem - : typeof rawDefinitionItem === 'object' && - rawDefinitionItem !== null && - 'resource_uri' in rawDefinitionItem - ? (rawDefinitionItem as { readonly resource_uri?: string }).resource_uri ?? '' - : ''; + const definitionItem = resource?.get('definitionItem'); const newDefinitionItem = - props.defaultValue ?? - definitionItem ?? - items?.slice(-1)[0]?.value ?? - ''; + props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; const isDifferentDefinitionItem = - (typeof rawDefinitionItem === 'object' && rawDefinitionItem !== null) || - newDefinitionItem !== definitionItem; + newDefinitionItem !== (definitionItem ?? ''); - const itemValues = items?.map(({ value }) => value); - const isKnownDefinitionItem = - itemValues === undefined || itemValues.includes(definitionItem); const invalidDefinitionItem = - definitionItem === '' || - (!isKnownDefinitionItem && + typeof definitionItem !== 'string' || + (items !== undefined && + !items.map(({ value }) => value).includes(definitionItem) && !Object.keys(resource?.changed ?? {}).includes('definitionitem')); if ( From 0d16699cb48445ee85bbc77260c0421925fdde9b Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 19:22:49 -0400 Subject: [PATCH 07/15] Adjust TreeRank picklist to preserve the existing definition item until valid options load --- .../PickLists/TreeLevelPickList.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index eeb4745a66c..f22e6c44ecd 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -143,27 +143,37 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { const definitionItem = resource?.get('definitionItem'); const newDefinitionItem = - props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; + props.defaultValue ?? items?.slice(-1)[0]?.value; + + if (typeof newDefinitionItem !== 'string') return undefined; + + const definitionItemValue = + typeof definitionItem === 'string' ? definitionItem : undefined; const isDifferentDefinitionItem = - newDefinitionItem !== (definitionItem ?? ''); + newDefinitionItem !== (definitionItemValue ?? ''); const invalidDefinitionItem = - typeof definitionItem !== 'string' || - (items !== undefined && - !items.map(({ value }) => value).includes(definitionItem) && + definitionItemValue === undefined || + (!(items + ?.map(({ value }) => value) + .includes(definitionItemValue) ?? true) && !Object.keys(resource?.changed ?? {}).includes('definitionitem')); + const isParentLoaded = typeof resource?.get('parent') !== 'string'; + const hasAvailableItems = (items?.length ?? 0) > 0; + if ( isDifferentDefinitionItem && - (items !== undefined || typeof resource?.get('parent') !== 'string') && - invalidDefinitionItem + invalidDefinitionItem && + hasAvailableItems && + isParentLoaded ) { resource?.set('definitionItem', newDefinitionItem); return void resource?.businessRuleManager?.checkField('parent'); } return undefined; - }, [items]); + }, [items, props.defaultValue, props.resource]); return ( Date: Wed, 29 Oct 2025 21:17:54 -0400 Subject: [PATCH 08/15] force revalidation after items load to clear false errors --- .../PickLists/TreeLevelPickList.tsx | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index f22e6c44ecd..4de221925d9 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -138,42 +138,44 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { }, [props.resource, props.defaultValue]); React.useEffect(() => { - if (props.resource === undefined) return undefined; + if (props.resource === undefined || items === undefined) return undefined; const resource = toTreeTable(props.resource); const definitionItem = resource?.get('definitionItem'); const newDefinitionItem = - props.defaultValue ?? items?.slice(-1)[0]?.value; - - if (typeof newDefinitionItem !== 'string') return undefined; - - const definitionItemValue = - typeof definitionItem === 'string' ? definitionItem : undefined; + props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; const isDifferentDefinitionItem = - newDefinitionItem !== (definitionItemValue ?? ''); + newDefinitionItem !== (definitionItem ?? ''); const invalidDefinitionItem = - definitionItemValue === undefined || - (!(items - ?.map(({ value }) => value) - .includes(definitionItemValue) ?? true) && + typeof definitionItem !== 'string' || + (!items.map(({ value }) => value).includes(definitionItem) && !Object.keys(resource?.changed ?? {}).includes('definitionitem')); - const isParentLoaded = typeof resource?.get('parent') !== 'string'; - const hasAvailableItems = (items?.length ?? 0) > 0; - if ( isDifferentDefinitionItem && - invalidDefinitionItem && - hasAvailableItems && - isParentLoaded + (items !== undefined || typeof resource?.get('parent') !== 'string') && + invalidDefinitionItem ) { resource?.set('definitionItem', newDefinitionItem); return void resource?.businessRuleManager?.checkField('parent'); } return undefined; - }, [items, props.defaultValue, props.resource]); + }, [items]); + + // Force revalidation when items are loaded + React.useEffect(() => { + if (items !== undefined && props.resource !== undefined) { + const resource = toTreeTable(props.resource); + const definitionItem = resource?.get('definitionItem'); + + // Trigger revalidation by touching the field + if (typeof definitionItem === 'string' && items.map(({ value }) => value).includes(definitionItem)) { + resource?.businessRuleManager?.checkField('definitionItem'); + } + } + }, [items, props.resource]); return ( Date: Wed, 29 Oct 2025 22:00:11 -0400 Subject: [PATCH 09/15] prevent false validation errors on existing tree nodes --- .../PickLists/TreeLevelPickList.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 4de221925d9..029306e1081 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -138,20 +138,30 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { }, [props.resource, props.defaultValue]); React.useEffect(() => { - if (props.resource === undefined || items === undefined) return undefined; + if (props.resource === undefined) return undefined; const resource = toTreeTable(props.resource); const definitionItem = resource?.get('definitionItem'); + // Don't run validation logic until items are loaded + if (items === undefined) return undefined; + const newDefinitionItem = props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; const isDifferentDefinitionItem = newDefinitionItem !== (definitionItem ?? ''); + // Check if current definitionItem is in the loaded items list + const isDefinitionItemInList = + typeof definitionItem === 'string' && + items.map(({ value }) => value).includes(definitionItem); + + const isDefinitionItemChanged = + Object.keys(resource?.changed ?? {}).includes('definitionitem'); + const invalidDefinitionItem = typeof definitionItem !== 'string' || - (!items.map(({ value }) => value).includes(definitionItem) && - !Object.keys(resource?.changed ?? {}).includes('definitionitem')); + (!isDefinitionItemInList && !isDefinitionItemChanged); if ( isDifferentDefinitionItem && @@ -164,15 +174,24 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { return undefined; }, [items]); - // Force revalidation when items are loaded + // Force field revalidation after items load to clear stale validation errors React.useEffect(() => { - if (items !== undefined && props.resource !== undefined) { - const resource = toTreeTable(props.resource); - const definitionItem = resource?.get('definitionItem'); + if (items === undefined || props.resource === undefined) return; + + const resource = toTreeTable(props.resource); + if (resource === undefined) return; + + const definitionItem = resource.get('definitionItem'); + + // Only revalidate if we have a valid definitionItem that exists in items + if (typeof definitionItem === 'string') { + const isInList = items.map(({ value }) => value).includes(definitionItem); - // Trigger revalidation by touching the field - if (typeof definitionItem === 'string' && items.map(({ value }) => value).includes(definitionItem)) { - resource?.businessRuleManager?.checkField('definitionItem'); + if (isInList) { + // Use setTimeout to ensure this runs after React finishes rendering + setTimeout(() => { + resource.businessRuleManager?.checkField('definitionItem'); + }, 0); } } }, [items, props.resource]); From da4a0905b54f59c40bb3279b7071935f35198fe7 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 22:44:58 -0400 Subject: [PATCH 10/15] normalize definitionItem to keep saved nodes valid --- .../PickLists/TreeLevelPickList.tsx | 112 ++++++++++-------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 029306e1081..8acb3f0bbd6 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -79,6 +79,33 @@ const ranksToPicklistItems = ( title: (rank.title?.length ?? 0) === 0 ? rank.name : rank.title!, })); +const getDefinitionItemUri = ( + definitionItem: + | string + | null + | undefined + | SerializedResource> + | SpecifyResource> +): string | undefined => { + if (typeof definitionItem === 'string') return definitionItem; + if (definitionItem !== null && typeof definitionItem === 'object') { + const specifyResource = definitionItem as SpecifyResource< + TreeDefItem + >; + if (typeof specifyResource.get === 'function') { + const resourceUri = specifyResource.get('resource_uri'); + if (typeof resourceUri === 'string') return resourceUri; + } + const serialized = definitionItem as Partial< + SerializedResource> + >; + if (typeof serialized.resource_uri === 'string') { + return serialized.resource_uri; + } + } + return undefined; +}; + /** * Pick list to choose a tree rank for a tree node */ @@ -138,63 +165,54 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { }, [props.resource, props.defaultValue]); React.useEffect(() => { - if (props.resource === undefined) return undefined; - const resource = toTreeTable(props.resource); - const definitionItem = resource?.get('definitionItem'); - - // Don't run validation logic until items are loaded - if (items === undefined) return undefined; + if (props.resource === undefined || items === undefined) return undefined; - const newDefinitionItem = - props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; + const resource = toTreeTable(props.resource); + if (resource === undefined) return undefined; - const isDifferentDefinitionItem = - newDefinitionItem !== (definitionItem ?? ''); + const definitionItem = resource.get('definitionItem'); + const definitionItemUri = getDefinitionItemUri(definitionItem); + const itemValues = items.map(({ value }: PickListItemSimple) => value); + const hasDefinitionItem = + typeof definitionItemUri === 'string' && + itemValues.includes(definitionItemUri); + + if (hasDefinitionItem) { + // Normalise stored value so business rules don't see an object vs string mismatch + if (typeof definitionItem !== 'string') { + resource.set('definitionItem', definitionItemUri); + } + resource.businessRuleManager?.checkField('definitionItem'); + resource.businessRuleManager?.checkField('parent'); + return undefined; + } - // Check if current definitionItem is in the loaded items list - const isDefinitionItemInList = - typeof definitionItem === 'string' && - items.map(({ value }) => value).includes(definitionItem); + const defaultDefinitionItem = + typeof props.defaultValue === 'string' && props.defaultValue.length > 0 + ? items.find( + ({ value, title }: PickListItemSimple) => + value === props.defaultValue || title === props.defaultValue + )?.value ?? props.defaultValue + : undefined; - const isDefinitionItemChanged = - Object.keys(resource?.changed ?? {}).includes('definitionitem'); + const fallbackDefinitionItem = + defaultDefinitionItem ?? itemValues[itemValues.length - 1]; - const invalidDefinitionItem = - typeof definitionItem !== 'string' || - (!isDefinitionItemInList && !isDefinitionItemChanged); + const hasUserChangedDefinitionItem = Object.keys( + resource.changed ?? {} + ).includes('definitionitem'); if ( - isDifferentDefinitionItem && - (items !== undefined || typeof resource?.get('parent') !== 'string') && - invalidDefinitionItem + typeof fallbackDefinitionItem === 'string' && + !hasUserChangedDefinitionItem ) { - resource?.set('definitionItem', newDefinitionItem); - return void resource?.businessRuleManager?.checkField('parent'); + resource.set('definitionItem', fallbackDefinitionItem); + resource.businessRuleManager?.checkField('definitionItem'); + resource.businessRuleManager?.checkField('parent'); } - return undefined; - }, [items]); - // Force field revalidation after items load to clear stale validation errors - React.useEffect(() => { - if (items === undefined || props.resource === undefined) return; - - const resource = toTreeTable(props.resource); - if (resource === undefined) return; - - const definitionItem = resource.get('definitionItem'); - - // Only revalidate if we have a valid definitionItem that exists in items - if (typeof definitionItem === 'string') { - const isInList = items.map(({ value }) => value).includes(definitionItem); - - if (isInList) { - // Use setTimeout to ensure this runs after React finishes rendering - setTimeout(() => { - resource.businessRuleManager?.checkField('definitionItem'); - }, 0); - } - } - }, [items, props.resource]); + return undefined; + }, [items, props.resource, props.defaultValue]); return ( Date: Thu, 30 Oct 2025 02:49:49 +0000 Subject: [PATCH 11/15] Lint code with ESLint and Prettier Triggered by da4a0905b54f59c40bb3279b7071935f35198fe7 on branch refs/heads/issue-7511 --- .../js_src/lib/components/PickLists/TreeLevelPickList.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 8acb3f0bbd6..9983b77e1b9 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -81,11 +81,7 @@ const ranksToPicklistItems = ( const getDefinitionItemUri = ( definitionItem: - | string - | null - | undefined - | SerializedResource> - | SpecifyResource> + SerializedResource> | SpecifyResource> | string | null | undefined ): string | undefined => { if (typeof definitionItem === 'string') return definitionItem; if (definitionItem !== null && typeof definitionItem === 'object') { @@ -196,7 +192,7 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { : undefined; const fallbackDefinitionItem = - defaultDefinitionItem ?? itemValues[itemValues.length - 1]; + defaultDefinitionItem ?? itemValues.at(-1); const hasUserChangedDefinitionItem = Object.keys( resource.changed ?? {} From 90d4f10d5e335277fb1d5f280d41dbf37b414f06 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 29 Oct 2025 23:07:56 -0400 Subject: [PATCH 12/15] Fix tree level picklist validation under slow loads --- .../components/PickLists/TreeLevelPickList.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 9983b77e1b9..843378fc4ae 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -110,9 +110,16 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { undefined ); + const treeResource = React.useMemo( + () => + props.resource === undefined ? undefined : toTreeTable(props.resource), + [props.resource] + ); + const definitionItemValue = treeResource?.get('definitionItem'); + React.useEffect(() => { if (props.resource === undefined) return undefined; - const resource = toTreeTable(props.resource); + const resource = treeResource; if ( resource === undefined || !hasTreeAccess(resource.specifyTable.name, 'read') @@ -161,9 +168,9 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { }, [props.resource, props.defaultValue]); React.useEffect(() => { - if (props.resource === undefined || items === undefined) return undefined; + if (treeResource === undefined || items === undefined) return undefined; - const resource = toTreeTable(props.resource); + const resource = treeResource; if (resource === undefined) return undefined; const definitionItem = resource.get('definitionItem'); @@ -208,7 +215,7 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { } return undefined; - }, [items, props.resource, props.defaultValue]); + }, [items, treeResource, props.defaultValue, definitionItemValue]); return ( Date: Wed, 29 Oct 2025 23:40:07 -0400 Subject: [PATCH 13/15] keep tree combo out of read-only path to avoid validation glitch --- .../PickLists/TreeLevelPickList.tsx | 91 ++++--------------- .../js_src/lib/components/PickLists/index.tsx | 5 +- 2 files changed, 23 insertions(+), 73 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx index 843378fc4ae..53dde00cb6b 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/TreeLevelPickList.tsx @@ -79,29 +79,6 @@ const ranksToPicklistItems = ( title: (rank.title?.length ?? 0) === 0 ? rank.name : rank.title!, })); -const getDefinitionItemUri = ( - definitionItem: - SerializedResource> | SpecifyResource> | string | null | undefined -): string | undefined => { - if (typeof definitionItem === 'string') return definitionItem; - if (definitionItem !== null && typeof definitionItem === 'object') { - const specifyResource = definitionItem as SpecifyResource< - TreeDefItem - >; - if (typeof specifyResource.get === 'function') { - const resourceUri = specifyResource.get('resource_uri'); - if (typeof resourceUri === 'string') return resourceUri; - } - const serialized = definitionItem as Partial< - SerializedResource> - >; - if (typeof serialized.resource_uri === 'string') { - return serialized.resource_uri; - } - } - return undefined; -}; - /** * Pick list to choose a tree rank for a tree node */ @@ -110,16 +87,9 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { undefined ); - const treeResource = React.useMemo( - () => - props.resource === undefined ? undefined : toTreeTable(props.resource), - [props.resource] - ); - const definitionItemValue = treeResource?.get('definitionItem'); - React.useEffect(() => { if (props.resource === undefined) return undefined; - const resource = treeResource; + const resource = toTreeTable(props.resource); if ( resource === undefined || !hasTreeAccess(resource.specifyTable.name, 'read') @@ -168,54 +138,31 @@ export function TreeLevelComboBox(props: DefaultComboBoxProps): JSX.Element { }, [props.resource, props.defaultValue]); React.useEffect(() => { - if (treeResource === undefined || items === undefined) return undefined; - - const resource = treeResource; - if (resource === undefined) return undefined; - - const definitionItem = resource.get('definitionItem'); - const definitionItemUri = getDefinitionItemUri(definitionItem); - const itemValues = items.map(({ value }: PickListItemSimple) => value); - const hasDefinitionItem = - typeof definitionItemUri === 'string' && - itemValues.includes(definitionItemUri); - - if (hasDefinitionItem) { - // Normalise stored value so business rules don't see an object vs string mismatch - if (typeof definitionItem !== 'string') { - resource.set('definitionItem', definitionItemUri); - } - resource.businessRuleManager?.checkField('definitionItem'); - resource.businessRuleManager?.checkField('parent'); - return undefined; - } + if (props.resource === undefined) return undefined; + const resource = toTreeTable(props.resource); + const definitionItem = resource?.get('definitionItem'); - const defaultDefinitionItem = - typeof props.defaultValue === 'string' && props.defaultValue.length > 0 - ? items.find( - ({ value, title }: PickListItemSimple) => - value === props.defaultValue || title === props.defaultValue - )?.value ?? props.defaultValue - : undefined; + const newDefinitionItem = + props.defaultValue ?? items?.slice(-1)[0]?.value ?? ''; - const fallbackDefinitionItem = - defaultDefinitionItem ?? itemValues.at(-1); + const isDifferentDefinitionItem = + newDefinitionItem !== (definitionItem ?? ''); - const hasUserChangedDefinitionItem = Object.keys( - resource.changed ?? {} - ).includes('definitionitem'); + const invalidDefinitionItem = + typeof definitionItem !== 'string' || + (!(items?.map(({ value }) => value).includes(definitionItem) ?? true) && + !Object.keys(resource?.changed ?? {}).includes('definitionitem')); if ( - typeof fallbackDefinitionItem === 'string' && - !hasUserChangedDefinitionItem + isDifferentDefinitionItem && + (items !== undefined || typeof resource?.get('parent') !== 'string') && + invalidDefinitionItem ) { - resource.set('definitionItem', fallbackDefinitionItem); - resource.businessRuleManager?.checkField('definitionItem'); - resource.businessRuleManager?.checkField('parent'); + resource?.set('definitionItem', newDefinitionItem); + return void resource?.businessRuleManager?.checkField('parent'); } - return undefined; - }, [items, treeResource, props.defaultValue, definitionItemValue]); + }, [items]); return ( From d6423fce32a598f4e9d863c06a76089f8e68cef6 Mon Sep 17 00:00:00 2001 From: Gitesh Sagvekar Date: Wed, 12 Nov 2025 22:35:12 -0500 Subject: [PATCH 14/15] keep tree rank picklist valid and also prevent typing for tree level rank picklist --- .../js_src/lib/components/PickLists/index.tsx | 76 ++++++++++++++----- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx index 9ae3b27eb03..5ed6871aa8c 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx @@ -24,6 +24,14 @@ import { Dialog } from '../Molecules/Dialog'; import { hasToolPermission } from '../Permissions/helpers'; import { PickListTypes } from './definitions'; +type PickListRawValue = + | SpecifyResource + | string + | number + | boolean + | null + | undefined; + export function PickListComboBox({ id, resource, @@ -90,13 +98,10 @@ export function PickListComboBox({ [defaultValue, rawIsRequired] ) ); - const value = React.useMemo( - () => - typeof rawValue === 'object' - ? ((rawValue as unknown as SpecifyResource)?.url() ?? null) - : ((rawValue as number | string | undefined)?.toString() ?? null), - [rawValue] - ); + const value = React.useMemo(() => { + if (isSpecifyResource(rawValue)) return rawValue.url?.() ?? null; + return (rawValue as number | string | undefined)?.toString() ?? null; + }, [rawValue]); const updateValue = React.useCallback( (value: string): void => @@ -136,9 +141,31 @@ export function PickListComboBox({ setPendingNewValue(value); else throw new Error('Adding item to wrong type of picklist'); } + const isSpecialByPrefix = + typeof pickListName === 'string' && pickListName.startsWith('_'); + const isSpecialPicklist = + isDisabled || + isSpecialByPrefix || + pickList?.get?.('readOnly') === true; const currentValue = items.find((item) => item.value === value); - const isExistingValue = typeof currentValue === 'object'; + const selectItems = React.useMemo(() => { + if ( + !isSpecialPicklist || + value === null || + items.some(({ value: itemValue }) => itemValue === value) + ) + return items; + const fallbackTitle = + currentValue?.title ?? getResourceFallbackTitle(rawValue) ?? value; + return [{ title: fallbackTitle, value }, ...items]; + }, [currentValue?.title, isSpecialPicklist, items, rawValue, value]); + const selectHasValue = React.useMemo( + () => + value !== null && + selectItems.some(({ value: itemValue }) => itemValue === value), + [selectItems, value] + ); const autocompleteItems = React.useMemo( () => @@ -159,14 +186,6 @@ export function PickListComboBox({ const isReadOnly = React.useContext(ReadOnlyContext); - const isTreeLevelComboBox = pickListName === '_treeLevelComboBox'; - const isSpecialByPrefix = - typeof pickListName === 'string' && pickListName.startsWith('_'); - const isSpecialPicklist = - isDisabled || - (isSpecialByPrefix && !isTreeLevelComboBox) || - pickList?.get?.('readOnly') === true; - return ( <> {isSpecialPicklist ? ( @@ -185,18 +204,18 @@ export function PickListComboBox({ : undefined } > - {isExistingValue ? ( + {selectHasValue ? ( parser.required === true ? undefined : ( )} - {items.map(({ title, value }) => ( + {selectItems.map(({ title, value }) => ( // If pick list has duplicate values, this triggers React warnings