From 2583a3a936e45df1b555609c2bc2449de2816b3b Mon Sep 17 00:00:00 2001 From: Daryn Brown Date: Sat, 5 Sep 2026 12:45:14 -0700 Subject: [PATCH 1/2] chore(types): tighten organisation unit name retrieval --- i18n/en.pot | 4 +- .../dataQueries/useOrganisationUnit.ts | 31 +++-- .../coreOrgUnit/useCoreOrgUnit.tsx | 8 +- .../orgUnitName/__tests__/orgUnitName.test.js | 42 +++++++ .../orgUnitName/orgUnitName.ts | 113 ++++++++++++------ .../orgUnitName/orgUnitName.types.ts | 2 +- 6 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 src/core_modules/capture-core/metadataRetrieval/orgUnitName/__tests__/orgUnitName.test.js diff --git a/i18n/en.pot b/i18n/en.pot index 424ac8f969..5d5c1194c9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-31T05:02:39.077Z\n" -"PO-Revision-Date: 2026-08-31T05:02:39.077Z\n" +"POT-Creation-Date: 2026-09-05T19:45:15.309Z\n" +"PO-Revision-Date: 2026-09-05T19:45:15.309Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/dataQueries/useOrganisationUnit.ts b/src/core_modules/capture-core/dataQueries/useOrganisationUnit.ts index 5e2f9b0766..8867cd5e6b 100644 --- a/src/core_modules/capture-core/dataQueries/useOrganisationUnit.ts +++ b/src/core_modules/capture-core/dataQueries/useOrganisationUnit.ts @@ -1,16 +1,29 @@ import { useMemo, useEffect, useState } from 'react'; -import { useDataQuery } from '@dhis2/app-runtime'; +import { useDataQuery, type FetchError } from '@dhis2/app-runtime'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; -export const useOrganisationUnit = (orgUnitId: string | null | undefined, fields?: string): { - orgUnit: any, - error: any, -} => { - const [orgUnit, setOrgUnit] = useState(); +type OrganisationUnit> = TFields & { + id: string, +}; + +type OrganisationUnitQueryResult> = { + organisationUnits: TFields, +}; + +type UseOrganisationUnitResult> = { + orgUnit?: OrganisationUnit, + error?: FetchError, +}; + +export const useOrganisationUnit = = Record>( + orgUnitId: string | null | undefined, + fields?: string, +): UseOrganisationUnitResult => { + const [orgUnit, setOrgUnit] = useState>(); const [requestedOrgUnitId, setRequestedOrgUnitId] = useState(); const [fetchingInProgress, setFetchingInProgress] = useState(false); - const { error, data, loading, refetch } = useDataQuery( + const { error, data, loading, refetch } = useDataQuery>( useMemo( () => ({ organisationUnits: { @@ -45,10 +58,10 @@ export const useOrganisationUnit = (orgUnitId: string | null | undefined, fields useEffect(() => { if (fetchingInProgress && !loading) { setFetchingInProgress(false); - if (orgUnitId === requestedOrgUnitId && !error && data?.organisationUnits) { + if (orgUnitId && orgUnitId === requestedOrgUnitId && !error && data?.organisationUnits) { setOrgUnit({ id: orgUnitId, - ...data.organisationUnits as Record, + ...data.organisationUnits, }); } } diff --git a/src/core_modules/capture-core/metadataRetrieval/coreOrgUnit/useCoreOrgUnit.tsx b/src/core_modules/capture-core/metadataRetrieval/coreOrgUnit/useCoreOrgUnit.tsx index 0b14108661..59eccbfa67 100644 --- a/src/core_modules/capture-core/metadataRetrieval/coreOrgUnit/useCoreOrgUnit.tsx +++ b/src/core_modules/capture-core/metadataRetrieval/coreOrgUnit/useCoreOrgUnit.tsx @@ -6,6 +6,12 @@ import { useOrganisationUnit } from '../../dataQueries'; import { orgUnitFetched } from './coreOrgUnit.actions'; import type { CoreOrgUnit } from './coreOrgUnit.types'; +type CoreOrgUnitFields = { + displayName: string, + code: string, + path: string, +}; + export function useCoreOrgUnit(orgUnitId: string): { orgUnit?: CoreOrgUnit, error?: any, @@ -14,7 +20,7 @@ export function useCoreOrgUnit(orgUnitId: string): { const reduxOrgUnit = useSelector(({ organisationUnits }: any) => organisationUnits && organisationUnits[orgUnitId]); const fetchId = reduxOrgUnit ? undefined : orgUnitId; // These hooks do no work when id is undefined - const { orgUnit, error } = useOrganisationUnit(fetchId, 'displayName,code,path'); + const { orgUnit, error } = useOrganisationUnit(fetchId, 'displayName,code,path'); const { orgUnitGroups, error: groupError } = useOrgUnitGroups(fetchId); if (reduxOrgUnit) { diff --git a/src/core_modules/capture-core/metadataRetrieval/orgUnitName/__tests__/orgUnitName.test.js b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/__tests__/orgUnitName.test.js new file mode 100644 index 0000000000..9510952f01 --- /dev/null +++ b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/__tests__/orgUnitName.test.js @@ -0,0 +1,42 @@ +import { + getAncestorIds, + getCachedOrgUnitName, + getOrgUnitNames, +} from '../orgUnitName'; + +describe('organisation unit name retrieval', () => { + it('returns subvalues and caches names and ancestors from the API response', async () => { + const rootId = 'org-unit-name-test-root'; + const childId = 'org-unit-name-test-child'; + const querySingleResource = jest.fn().mockResolvedValue({ + organisationUnits: [ + { + id: childId, + displayName: 'Child organisation unit', + ancestors: [ + { + id: rootId, + displayName: 'Root organisation unit', + }, + ], + }, + ], + }); + + await expect(getOrgUnitNames([childId], querySingleResource)).resolves.toEqual({ + [childId]: { + id: childId, + name: 'Child organisation unit', + }, + }); + expect(querySingleResource).toHaveBeenCalledWith( + expect.objectContaining({ resource: 'organisationUnits' }), + { filter: childId }, + ); + expect(getCachedOrgUnitName(childId)).toBe('Child organisation unit'); + expect(getCachedOrgUnitName(rootId)).toBe('Root organisation unit'); + + await expect(getAncestorIds(childId, querySingleResource)).resolves.toEqual([rootId]); + expect(querySingleResource).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.ts b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.ts index 723377da99..4a44bf42a4 100644 --- a/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.ts +++ b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.ts @@ -1,12 +1,47 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; -import { useDataQuery } from '@dhis2/app-runtime'; +import { useDataQuery, type FetchError } from '@dhis2/app-runtime'; import { useOrganisationUnit } from '../../dataQueries'; import type { OrgUnitNames } from './orgUnitName.types'; import type { QuerySingleResource } from '../../utils/api'; +type OrganisationUnitAncestor = { + id: string, + displayName: string, +}; + +type OrganisationUnitDetails = { + displayName: string, + ancestors: Array, +}; + +type ApiOrganisationUnit = OrganisationUnitDetails & { + id: string, +}; + +type CachedOrganisationUnit = { + displayName: string, + ancestor?: string, +}; + +type OrganisationUnitsResponse = { + organisationUnits: Array, +}; + +type DisplayNamesQueryResult = { + organisationUnits: OrganisationUnitsResponse, +}; + +type OrgUnitSubValue = { + id: string, + name?: string, +}; + +type OrgUnitSubValues = Record; +type AncestorProperty = 'id' | 'displayName'; + // Avoid exporting displayNameCache to keep it truly private. // As a consequence all functions using it must be in this file. -const displayNameCache: any = {}; +const displayNameCache: Record = {}; const maxBatchSize = 50; const fields = 'id,displayName,ancestors[id,displayName]'; @@ -15,9 +50,9 @@ const resource = 'organisationUnits'; const displayNamesQuery = { organisationUnits: { resource, - params: ({ filter }: any) => ({ + params: (variables: Record) => ({ fields, - filter: `id:in:[${filter}]`, + filter: `id:in:[${typeof variables.filter === 'string' ? variables.filter : ''}]`, pageSize: maxBatchSize, }), }, @@ -31,15 +66,15 @@ const displayNameQuery = (orgUnitId: string) => ({ }, }); -const updateCacheWithOrgUnits = (organisationUnits: any) => { - organisationUnits.forEach(({ id, displayName, ancestors }: any) => { +const updateCacheWithOrgUnits = (organisationUnits: Array) => { + organisationUnits.forEach(({ id, displayName, ancestors }) => { if (ancestors.length > 0) { displayNameCache[id] = { displayName, ancestor: ancestors[ancestors.length - 1].id, }; - ancestors.findLast((ancestor: any, index: any) => { + ancestors.findLast((ancestor, index) => { if (displayNameCache[ancestor.id]) { // Ancestors already cached return true; @@ -79,13 +114,18 @@ const createBatches = (orgUnitIds: Array): Array> => { return batches; }; -const getAncestors = (orgUnitId: any, property: any) => { - const orgUnit = orgUnitId && displayNameCache[orgUnitId]; +const getAncestors = (orgUnitId: string | undefined, property: AncestorProperty): Array => { + if (!orgUnitId) { + return []; + } - if (!orgUnit) return []; + const orgUnit = displayNameCache[orgUnitId]; + if (!orgUnit) { + return []; + } const ancestors = getAncestors(orgUnit.ancestor, property); - ancestors.push(property === 'id' ? orgUnitId : orgUnit[property]); + ancestors.push(property === 'id' ? orgUnitId : orgUnit.displayName); return ancestors; }; @@ -93,15 +133,15 @@ const getAncestors = (orgUnitId: any, property: any) => { // Works best with memoized input arrays. export const useOrgUnitNames = (orgUnitIds: Array): { loading: boolean, - orgUnitNames: OrgUnitNames | null, - error: any, + orgUnitNames?: OrgUnitNames, + error?: FetchError, } => { const [fetching, setFetching] = useState(false); const [fetchNextBatch, setFetchNextBatch] = useState(false); - const [requestedArray, setRequestedArray] = useState(); + const [requestedArray, setRequestedArray] = useState>(); const [currentBatches, setCurrentBatches] = useState>>([]); const [completedBatches, setCompletedBatches] = useState(0); - const [error, setError] = useState(); + const [error, setError] = useState(); const ready = !fetching && orgUnitIds === requestedArray; @@ -109,12 +149,12 @@ export const useOrgUnitNames = (orgUnitIds: Array): { const filter = useMemo(() => ( fetching ? currentBatches[completedBatches].join(',') : '' ), [fetching, currentBatches, completedBatches]); - const result = useMemo(() => (ready ? orgUnitIds.reduce((acc: any, id) => { + const result = useMemo(() => (ready ? orgUnitIds.reduce((acc, id) => { acc[id] = displayNameCache[id] ? displayNameCache[id].displayName : null; return acc; - }, {}) : null), [ready, orgUnitIds]); + }, {}) : undefined), [ready, orgUnitIds]); - const onComplete = useCallback(({ organisationUnits }: any) => { + const onComplete = useCallback(({ organisationUnits }: DisplayNamesQueryResult) => { updateCacheWithOrgUnits(organisationUnits.organisationUnits); const completeCount = completedBatches + 1; @@ -127,12 +167,12 @@ export const useOrgUnitNames = (orgUnitIds: Array): { } }, [completedBatches, setCompletedBatches, currentBatches, setFetching, setFetchNextBatch]); - const onError = useCallback((fetchError: any) => { + const onError = useCallback((fetchError: FetchError) => { setFetching(false); setError(fetchError); }, [setFetching, setError]); - const { refetch } = useDataQuery(displayNamesQuery, { + const { refetch } = useDataQuery(displayNamesQuery, { variables: { filter }, onComplete, onError, @@ -168,21 +208,19 @@ export const useOrgUnitNames = (orgUnitIds: Array): { }; }; -export async function getOrgUnitNames(orgUnitIds: Array, querySingleResource: QuerySingleResource): Promise<{ -[orgUnitId: string]: { - id: string, - displayName: string, - } -}> { +export async function getOrgUnitNames( + orgUnitIds: Array, + querySingleResource: QuerySingleResource, +): Promise { await Promise.all(createBatches(orgUnitIds) .map(batch => querySingleResource(displayNamesQuery.organisationUnits, { filter: batch.join(',') }) - .then(({ organisationUnits }: any) => { + .then(({ organisationUnits }: OrganisationUnitsResponse) => { updateCacheWithOrgUnits(organisationUnits); }), ), ); - return orgUnitIds.reduce((acc: any, orgUnitId) => { + return orgUnitIds.reduce((acc, orgUnitId) => { acc[orgUnitId] = { id: orgUnitId, name: displayNameCache[orgUnitId]?.displayName, @@ -194,11 +232,14 @@ export async function getOrgUnitNames(orgUnitIds: Array, querySingleReso export const useOrgUnitNameWithAncestors = (orgUnitId?: string | null): { displayName?: string, ancestors?: Array, - error: any, + error?: FetchError, } => { const cachedOrgUnit = orgUnitId && displayNameCache[orgUnitId]; const fetchId = cachedOrgUnit ? undefined : orgUnitId; - const { orgUnit: fetchedOrgUnit, error } = useOrganisationUnit(fetchId, 'displayName,ancestors[id,displayName]'); + const { orgUnit: fetchedOrgUnit, error } = useOrganisationUnit( + fetchId, + 'displayName,ancestors[id,displayName]', + ); if (orgUnitId && cachedOrgUnit) { const ancestors = getAncestors(cachedOrgUnit.ancestor, 'displayName'); @@ -210,7 +251,7 @@ export const useOrgUnitNameWithAncestors = (orgUnitId?: string | null): { }; } else if (fetchedOrgUnit && fetchId) { updateCacheWithOrgUnits([fetchedOrgUnit]); - const ancestors = fetchedOrgUnit.ancestors.map((ancestor: any) => ancestor.displayName); + const ancestors = fetchedOrgUnit.ancestors.map(ancestor => ancestor.displayName); return { displayName: fetchedOrgUnit.displayName, @@ -222,15 +263,19 @@ export const useOrgUnitNameWithAncestors = (orgUnitId?: string | null): { return { error }; }; -export const getAncestorIds = async (orgUnitId: string, querySingleResource: QuerySingleResource) => { +export const getAncestorIds = async ( + orgUnitId: string, + querySingleResource: QuerySingleResource, +): Promise> => { const cachedOrgUnit = displayNameCache[orgUnitId]; if (cachedOrgUnit) { return getAncestors(cachedOrgUnit.ancestor, 'id'); } - const apiOrgUnit = await querySingleResource(displayNameQuery(orgUnitId)); + const apiOrgUnit: ApiOrganisationUnit = await querySingleResource(displayNameQuery(orgUnitId)); updateCacheWithOrgUnits([apiOrgUnit]); return getAncestors(displayNameCache[orgUnitId].ancestor, 'id'); }; -export const getCachedOrgUnitName = (orgUnitId: string): string | null => displayNameCache[orgUnitId]?.displayName; +export const getCachedOrgUnitName = (orgUnitId: string): string | undefined => + displayNameCache[orgUnitId]?.displayName; diff --git a/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.types.ts b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.types.ts index 3d8d92ea85..36a3668bcc 100644 --- a/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.types.ts +++ b/src/core_modules/capture-core/metadataRetrieval/orgUnitName/orgUnitName.types.ts @@ -1,3 +1,3 @@ export type OrgUnitNames = { - [orgUnitId: string]: string, + [orgUnitId: string]: string | null, }; From 6c93eaba3f8a915b5e67bb332a7d2ffa4f3eb8de Mon Sep 17 00:00:00 2001 From: Daryn Brown Date: Sat, 5 Sep 2026 12:45:49 -0700 Subject: [PATCH 2/2] chore(i18n): restore catalog timestamp --- i18n/en.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 5d5c1194c9..424ac8f969 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-05T19:45:15.309Z\n" -"PO-Revision-Date: 2026-09-05T19:45:15.309Z\n" +"POT-Creation-Date: 2026-08-31T05:02:39.077Z\n" +"PO-Revision-Date: 2026-08-31T05:02:39.077Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded."