From f2f36dec93bd054545c1d821df72f30fba0c116b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:24:03 +0000 Subject: [PATCH 01/24] feat: add custom plural for note relationship attribute --- i18n/en.pot | 4 +- .../helpers/customLabels/customLabels.ts | 44 ++++++++++--------- .../TrackedEntityTypeFactory.ts | 5 ++- .../quickStoreOperations/storePrograms.ts | 3 ++ .../types/apiPrograms.types.ts | 3 ++ .../storageControllers/types/cache.types.ts | 3 ++ 6 files changed, 38 insertions(+), 24 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d04cbca43f..9b3d4b0088 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-07-21T13:35:51.419Z\n" -"PO-Revision-Date: 2026-07-21T13:35:51.419Z\n" +"POT-Creation-Date: 2026-07-23T08:24:04.879Z\n" +"PO-Revision-Date: 2026-07-23T08:24:04.880Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 18938840cd..0366c64f64 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -1,37 +1,40 @@ +import { capitalizeFirstLetter } from 'capture-core-utils/string'; + type CustomLabelField = { - field?: string, - pluralField?: string, + singular?: string, + plural?: string, }; export const CUSTOM_LABEL_FIELDS = { - enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, - followUp: { field: 'displayFollowUpLabel' }, - orgUnit: { field: 'displayOrgUnitLabel' }, - relationship: { field: 'displayRelationshipLabel' }, - note: { field: 'displayNoteLabel' }, - attribute: { field: 'displayTrackedEntityAttributeLabel' }, - programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, - event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, - trackedEntityType: { pluralField: 'displayTrackedEntityTypesLabel' }, + enrollment: { singular: 'displayEnrollmentLabel', plural: 'displayEnrollmentsLabel' }, + followUp: { singular: 'displayFollowUpLabel' }, + orgUnit: { singular: 'displayOrgUnitLabel' }, + note: { singular: 'displayNoteLabel', plural: 'displayNotesLabel' }, + relationship: { singular: 'displayRelationshipLabel', plural: 'displayRelationshipsLabel' }, + attribute: { singular: 'displayTrackedEntityAttributeLabel', plural: 'displayTrackedEntityAttributesLabel' }, + programStage: { singular: 'displayProgramStageLabel', plural: 'displayProgramStagesLabel' }, + event: { singular: 'displayEventLabel', plural: 'displayEventsLabel' }, + trackedEntityType: { singular: 'displayTrackedEntityTypeLabel', plural: 'displayTrackedEntityTypesLabel' }, } as const satisfies { [key: string]: CustomLabelField }; export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; export type CustomLabels = Record; export type LabelOptions = { plural?: boolean }; -const allFields: Array = Array.from( +const ALL_FIELDS: ReadonlyArray = Array.from( new Set( Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) + .flatMap((term: CustomLabelField) => [term.singular, term.plural]) .filter((field): field is string => Boolean(field)), ), ); -export const extractCustomLabels = (cached: Record): CustomLabels => { +export const extractCustomLabels = (cached: Record): CustomLabels => { const labels: CustomLabels = {}; - allFields.forEach((field) => { - if (cached[field]) { - labels[field] = cached[field]; + ALL_FIELDS.forEach((field) => { + const value = cached[field]; + if (typeof value === 'string' && value) { + labels[field] = value; } }); return labels; @@ -48,10 +51,9 @@ export const resolveLabel = ( const list = Array.isArray(sources) ? sources : [sources]; const pick = (field?: string) => (field ? list.find(source => source?.[field])?.[field] : undefined); - if (plural) { - return term.pluralField ? pick(term.pluralField) : pick(term.field); - } - return pick(term.field); + const field = plural && term.plural ? term.plural : term.singular; + const value = pick(field); + return value ? capitalizeFirstLetter(value) : value; }; type WithLabels = { customLabels?: CustomLabels } | undefined | null; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index 7e5e9d5a91..3abe925f6e 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -87,7 +87,10 @@ export class TrackedEntityTypeFactory { o.name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - o.customLabels = extractCustomLabels(cachedType); + o.customLabels = extractCustomLabels({ + ...cachedType, + displayTrackedEntityTypeLabel: cachedType.displayName, + }); }); if (cachedType.trackedEntityTypeAttributes) { diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts index 929645433c..423bb6c1c3 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -142,8 +142,11 @@ const fieldsParam = [ 'displayFollowUpLabel', 'displayOrgUnitLabel', 'displayRelationshipLabel', + 'displayRelationshipsLabel', 'displayNoteLabel', + 'displayNotesLabel', 'displayTrackedEntityAttributeLabel', + 'displayTrackedEntityAttributesLabel', 'displayProgramStageLabel', 'displayProgramStagesLabel', 'displayEventLabel', diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts index 676c11b68a..4849b10ab2 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts @@ -147,8 +147,11 @@ type apiProgram = { displayFollowUpLabel?: string | null, displayOrgUnitLabel?: string | null, displayRelationshipLabel?: string | null, + displayRelationshipsLabel?: string | null, displayNoteLabel?: string | null, + displayNotesLabel?: string | null, displayTrackedEntityAttributeLabel?: string | null, + displayTrackedEntityAttributesLabel?: string | null, displayProgramStageLabel?: string | null, displayProgramStagesLabel?: string | null, displayEventLabel?: string | null, diff --git a/src/core_modules/capture-core/storageControllers/types/cache.types.ts b/src/core_modules/capture-core/storageControllers/types/cache.types.ts index 771a55c14b..d903c0ab50 100644 --- a/src/core_modules/capture-core/storageControllers/types/cache.types.ts +++ b/src/core_modules/capture-core/storageControllers/types/cache.types.ts @@ -215,8 +215,11 @@ export type CachedProgram = { displayFollowUpLabel?: string | null, displayOrgUnitLabel?: string | null, displayRelationshipLabel?: string | null, + displayRelationshipsLabel?: string | null, displayNoteLabel?: string | null, + displayNotesLabel?: string | null, displayTrackedEntityAttributeLabel?: string | null, + displayTrackedEntityAttributesLabel?: string | null, displayProgramStageLabel?: string | null, displayProgramStagesLabel?: string | null, displayEventLabel?: string | null, From 18351e92b5ddeb6021da8ea5f7616466c3bf751b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:09:36 +0000 Subject: [PATCH 02/24] feat: update name assignment in TrackedEntityTypeFactory to use translated name --- i18n/en.pot | 4 ++-- .../factory/TrackedEntityType/TrackedEntityTypeFactory.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9b3d4b0088..24bb1fa3ed 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-07-23T08:24:04.879Z\n" -"PO-Revision-Date: 2026-07-23T08:24:04.880Z\n" +"POT-Creation-Date: 2026-07-23T09:09:38.136Z\n" +"PO-Revision-Date: 2026-07-23T09:09:38.136Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index 3abe925f6e..e5c1359aae 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -84,12 +84,13 @@ export class TrackedEntityTypeFactory { const trackedEntityType = new TrackedEntityType((o) => { o.id = cachedType.id; o.access = cachedType.access; - o.name = this._getTranslation( + const name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; + o.name = name; o.customLabels = extractCustomLabels({ ...cachedType, - displayTrackedEntityTypeLabel: cachedType.displayName, + displayTrackedEntityTypeLabel: name, }); }); From 5a895cdb618890effbed44faef4547940460d6b3 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:36:45 +0000 Subject: [PATCH 03/24] feat: metadata loading support custom plural labels based on server version --- i18n/en.pot | 4 +- src/components/AppLoader/init.ts | 2 +- .../baseLoader/loadMetaData.ts | 3 +- .../metaDataStoreLoaders/context/context.ts | 2 + .../context/context.types.ts | 1 + .../quickStoreOperations/storePrograms.ts | 48 ++++++++++++++----- .../storeTrackedEntityTypes.ts | 15 ++++-- 7 files changed, 54 insertions(+), 21 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 24bb1fa3ed..5557630958 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-07-23T09:09:38.136Z\n" -"PO-Revision-Date: 2026-07-23T09:09:38.136Z\n" +"POT-Creation-Date: 2026-07-23T10:36:47.713Z\n" +"PO-Revision-Date: 2026-07-23T10:36:47.713Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/components/AppLoader/init.ts b/src/components/AppLoader/init.ts index 4418f77ba7..1b6d5465bb 100644 --- a/src/components/AppLoader/init.ts +++ b/src/components/AppLoader/init.ts @@ -121,7 +121,7 @@ async function setLocaleDataAsync(uiLocale: string) { } async function initializeMetaDataAsync(dbLocale: string, onQueryApi: any, minorServerVersion: number) { - await loadMetaData(onQueryApi); + await loadMetaData(onQueryApi, minorServerVersion); await buildMetaDataAsync(dbLocale, minorServerVersion); } diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts b/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts index ad544521e6..7e5261b959 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts @@ -3,10 +3,11 @@ import { provideContext } from '../context'; import { loadMetaDataInternal } from './loadMetaDataInternal'; import type { QuerySingleResource } from '../../utils/api'; -export const loadMetaData = async (onQueryApi: QuerySingleResource) => { +export const loadMetaData = async (onQueryApi: QuerySingleResource, minorServerVersion: number) => { await provideContext({ onQueryApi, storageController: getUserMetadataStorageController(), storeNames: USER_METADATA_STORES, + minorServerVersion, }, loadMetaDataInternal); }; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts index fa5346222f..9af346c360 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts @@ -7,12 +7,14 @@ export const provideContext = async ( onQueryApi, storageController, storeNames, + minorServerVersion, }: ContextInput, callback: any) => { context = { onQueryApi, storageController, storeNames, + minorServerVersion, }; await callback(); context = null; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts index 43d07e0e7a..bf7a464819 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts @@ -24,4 +24,5 @@ export type ContextInput = { onQueryApi: QuerySingleResource, storageController: StorageController, storeNames: StoreNames, + minorServerVersion: number, }; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts index 423bb6c1c3..5280eb4240 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -79,6 +79,8 @@ const convert = (() => { }; })(); +const CUSTOM_PLURAL_LABELS_MIN_VERSION = 43; + const programStageDataElementFields = [ 'compulsory', 'displayInReports', @@ -97,7 +99,7 @@ const programTrackedEntityAttributeFields = [ 'allowFutureDate', ].join(','); -const programStageFields = [ +const baseProgramStageFields = [ 'id', 'access', 'autoGenerateEvent', @@ -117,7 +119,6 @@ const programStageFields = [ 'displayDueDateLabel', 'displayProgramStageLabel', 'displayEventLabel', - 'displayEventsLabel', 'formType', 'featureType', 'validationStrategy', @@ -126,9 +127,13 @@ const programStageFields = [ 'dataEntryForm[id,htmlCode]', 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]]', `programStageDataElements[${programStageDataElementFields}]`, -].join(','); +]; + +const pluralProgramStageFields = [ + 'displayEventsLabel', +]; -const fieldsParam = [ +const baseProgramFields = [ 'id', 'displayName', 'displayShortName', @@ -138,19 +143,13 @@ const fieldsParam = [ 'displayIncidentDateLabel', 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', - 'displayEnrollmentsLabel', 'displayFollowUpLabel', 'displayOrgUnitLabel', 'displayRelationshipLabel', - 'displayRelationshipsLabel', 'displayNoteLabel', - 'displayNotesLabel', 'displayTrackedEntityAttributeLabel', - 'displayTrackedEntityAttributesLabel', 'displayProgramStageLabel', - 'displayProgramStagesLabel', 'displayEventLabel', - 'displayEventsLabel', 'minAttributesRequiredToSearch', 'useFirstStageDuringRegistration', 'onlyEnrollOnce', @@ -166,16 +165,39 @@ const fieldsParam = [ 'access[data[read,write]]', 'trackedEntityType[id]', 'categoryCombo[id,displayName,isDefault,categories[id,displayName]]', - `programStages[${programStageFields}]`, 'programSections[id, displayDescription, displayFormName, sortOrder, trackedEntityAttributes]', `programTrackedEntityAttributes[${programTrackedEntityAttributeFields}]`, -].join(','); +]; + +const pluralProgramFields = [ + 'displayEnrollmentsLabel', + 'displayRelationshipsLabel', + 'displayNotesLabel', + 'displayTrackedEntityAttributesLabel', + 'displayProgramStagesLabel', + 'displayEventsLabel', +]; + +const buildFieldsParam = (includePluralLabels: boolean): string => { + const stageFields = includePluralLabels + ? [...baseProgramStageFields, ...pluralProgramStageFields] + : baseProgramStageFields; + const programFields = includePluralLabels + ? [...baseProgramFields, ...pluralProgramFields] + : baseProgramFields; + return [ + ...programFields, + `programStages[${stageFields.join(',')}]`, + ].join(','); +}; export const storePrograms = (programIds: Array) => { + const { minorServerVersion } = getContext(); + const includePluralLabels = minorServerVersion >= CUSTOM_PLURAL_LABELS_MIN_VERSION; const query = { resource: 'programs', params: { - fields: fieldsParam, + fields: buildFieldsParam(includePluralLabels), filter: `id:in:[${programIds.join(',')}]`, pageSize: programIds.length, }, diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts index 1e6f04c161..01d91056c3 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -26,15 +26,22 @@ const convert = (() => { })); })(); -const fieldsParam = 'id,access,displayName,displayTrackedEntityTypesLabel,minAttributesRequiredToSearch,featureType,' + - 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + - 'translations[property,locale,value]'; +const CUSTOM_PLURAL_LABELS_MIN_VERSION = 43; + +const buildFieldsParam = (includePluralLabels: boolean): string => { + const labels = includePluralLabels ? 'displayName,displayTrackedEntityTypesLabel' : 'displayName'; + return `id,access,${labels},minAttributesRequiredToSearch,featureType,` + + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + + 'translations[property,locale,value]'; +}; export const storeTrackedEntityTypes = (ids: Array) => { + const { minorServerVersion } = getContext(); + const includePluralLabels = minorServerVersion >= CUSTOM_PLURAL_LABELS_MIN_VERSION; const query = { resource: 'trackedEntityTypes', params: { - fields: fieldsParam, + fields: buildFieldsParam(includePluralLabels), filter: `id:in:[${ids.join(',')}]`, pageSize: ids.length, }, From 8cedb6814ee212fa3fd24a54413cbf34beb9ab54 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:12:51 +0000 Subject: [PATCH 04/24] feat: add customTerminologyPlurals feature support --- i18n/en.pot | 4 ++-- src/components/AppLoader/init.ts | 2 +- .../capture-core-utils/featuresSupport/support.ts | 2 ++ .../metaDataStoreLoaders/baseLoader/loadMetaData.ts | 3 +-- .../capture-core/metaDataStoreLoaders/context/context.ts | 2 -- .../metaDataStoreLoaders/context/context.types.ts | 1 - .../programs/quickStoreOperations/storePrograms.ts | 6 ++---- .../quickStoreOperations/storeTrackedEntityTypes.ts | 6 ++---- 8 files changed, 10 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 5557630958..c7a795cd61 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-07-23T10:36:47.713Z\n" -"PO-Revision-Date: 2026-07-23T10:36:47.713Z\n" +"POT-Creation-Date: 2026-07-23T11:12:53.721Z\n" +"PO-Revision-Date: 2026-07-23T11:12:53.722Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/components/AppLoader/init.ts b/src/components/AppLoader/init.ts index 1b6d5465bb..4418f77ba7 100644 --- a/src/components/AppLoader/init.ts +++ b/src/components/AppLoader/init.ts @@ -121,7 +121,7 @@ async function setLocaleDataAsync(uiLocale: string) { } async function initializeMetaDataAsync(dbLocale: string, onQueryApi: any, minorServerVersion: number) { - await loadMetaData(onQueryApi, minorServerVersion); + await loadMetaData(onQueryApi); await buildMetaDataAsync(dbLocale, minorServerVersion); } diff --git a/src/core_modules/capture-core-utils/featuresSupport/support.ts b/src/core_modules/capture-core-utils/featuresSupport/support.ts index 5a0bd6c001..2dbca8e779 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.ts +++ b/src/core_modules/capture-core-utils/featuresSupport/support.ts @@ -18,6 +18,7 @@ export const FEATURES = Object.freeze({ orgUnitReplaceOuQueryParam: 'orgUnitReplaceOuQueryParam', enrollmentStatusReplaceProgramStatusQueryParam: 'enrollmentStatusReplaceProgramStatusQueryParam', emptyValueFilter: 'emptyValueFilter', + customTerminologyPlurals: 'customTerminologyPlurals', }); const MINOR_VERSION_SUPPORT = Object.freeze({ @@ -40,6 +41,7 @@ const MINOR_VERSION_SUPPORT = Object.freeze({ [FEATURES.orgUnitReplaceOuQueryParam]: 42, [FEATURES.enrollmentStatusReplaceProgramStatusQueryParam]: 42, [FEATURES.emptyValueFilter]: 42, + [FEATURES.customTerminologyPlurals]: 43, }); export const hasAPISupportForFeature = (minorVersion: string | number, featureName: string) => diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts b/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts index 7e5261b959..ad544521e6 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/baseLoader/loadMetaData.ts @@ -3,11 +3,10 @@ import { provideContext } from '../context'; import { loadMetaDataInternal } from './loadMetaDataInternal'; import type { QuerySingleResource } from '../../utils/api'; -export const loadMetaData = async (onQueryApi: QuerySingleResource, minorServerVersion: number) => { +export const loadMetaData = async (onQueryApi: QuerySingleResource) => { await provideContext({ onQueryApi, storageController: getUserMetadataStorageController(), storeNames: USER_METADATA_STORES, - minorServerVersion, }, loadMetaDataInternal); }; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts index 9af346c360..fa5346222f 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.ts @@ -7,14 +7,12 @@ export const provideContext = async ( onQueryApi, storageController, storeNames, - minorServerVersion, }: ContextInput, callback: any) => { context = { onQueryApi, storageController, storeNames, - minorServerVersion, }; await callback(); context = null; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts index bf7a464819..43d07e0e7a 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/context/context.types.ts @@ -24,5 +24,4 @@ export type ContextInput = { onQueryApi: QuerySingleResource, storageController: StorageController, storeNames: StoreNames, - minorServerVersion: number, }; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts index 5280eb4240..48a9dddec6 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -1,3 +1,4 @@ +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; import { quickStore } from '../../IOUtils'; import { getContext } from '../../context'; import type { CachedProgramStageDataElement } from '../../../storageControllers'; @@ -79,8 +80,6 @@ const convert = (() => { }; })(); -const CUSTOM_PLURAL_LABELS_MIN_VERSION = 43; - const programStageDataElementFields = [ 'compulsory', 'displayInReports', @@ -192,8 +191,7 @@ const buildFieldsParam = (includePluralLabels: boolean): string => { }; export const storePrograms = (programIds: Array) => { - const { minorServerVersion } = getContext(); - const includePluralLabels = minorServerVersion >= CUSTOM_PLURAL_LABELS_MIN_VERSION; + const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const query = { resource: 'programs', params: { diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts index 01d91056c3..bb12dba43c 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -1,3 +1,4 @@ +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; import { quickStore } from '../../IOUtils'; import { getContext } from '../../context'; @@ -26,8 +27,6 @@ const convert = (() => { })); })(); -const CUSTOM_PLURAL_LABELS_MIN_VERSION = 43; - const buildFieldsParam = (includePluralLabels: boolean): string => { const labels = includePluralLabels ? 'displayName,displayTrackedEntityTypesLabel' : 'displayName'; return `id,access,${labels},minAttributesRequiredToSearch,featureType,` + @@ -36,8 +35,7 @@ const buildFieldsParam = (includePluralLabels: boolean): string => { }; export const storeTrackedEntityTypes = (ids: Array) => { - const { minorServerVersion } = getContext(); - const includePluralLabels = minorServerVersion >= CUSTOM_PLURAL_LABELS_MIN_VERSION; + const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const query = { resource: 'trackedEntityTypes', params: { From c4799537879dc4d98bfe3ae02e0b7a088a0a790a Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:56:46 +0000 Subject: [PATCH 05/24] fix: separate base and plural fields for improved readability --- i18n/en.pot | 4 +-- .../WidgetEnrollment/hooks/useProgram.ts | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 00e43fbda8..b19a9f94f4 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-06T09:39:23.801Z\n" -"PO-Revision-Date: 2026-08-06T09:39:23.801Z\n" +"POT-Creation-Date: 2026-08-06T09:56:48.432Z\n" +"PO-Revision-Date: 2026-08-06T09:56:48.432Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts index 048ac5018c..1e2d99395a 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -1,11 +1,27 @@ import { useMemo } from 'react'; import { useDataQuery } from '@dhis2/app-runtime'; +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; type ProgramData = { featureType: string; [key: string]: any; }; +const baseFields = [ + 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + + 'displayEnrollmentLabel,displayFollowUpLabel,displayOrgUnitLabel,' + + 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + + 'displayProgramStageLabel,displayEventLabel,' + + 'trackedEntityType[displayName,access],' + + 'programStages[autoGenerateEvent,name,access,id],' + + 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', +]; + +const pluralFields = [ + 'displayEnrollmentsLabel,displayRelationshipsLabel,displayNotesLabel,' + + 'displayTrackedEntityAttributesLabel,displayProgramStagesLabel,displayEventsLabel', +]; + export const useProgram = (programId: string) => { const { error, loading, data } = useDataQuery( useMemo( @@ -13,15 +29,9 @@ export const useProgram = (programId: string) => { program: { resource: `programs/${programId}`, params: { - fields: [ - 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + - 'displayEnrollmentLabel,displayEnrollmentsLabel,displayFollowUpLabel,displayOrgUnitLabel,' + - 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + - 'displayProgramStageLabel,displayProgramStagesLabel,displayEventLabel,displayEventsLabel,' + - 'trackedEntityType[displayName,access],' + - 'programStages[autoGenerateEvent,name,access,id],' + - 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', - ], + fields: featureAvailable(FEATURES.customTerminologyPlurals) + ? [...baseFields, ...pluralFields] + : baseFields, }, }, }), From e5b4d74be08d88ba699fca48a90904c786796132 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:49:33 +0000 Subject: [PATCH 06/24] feat: temp change helper function and stop capitalize --- i18n/en.pot | 4 +- .../metaData/helpers/customLabels/index.ts | 8 ++-- .../metaData/helpers/customLabels/useLabel.ts | 45 ------------------- .../capture-core/metaData/helpers/index.ts | 10 ++--- .../capture-core/metaData/index.ts | 10 ++--- 5 files changed, 11 insertions(+), 66 deletions(-) delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts diff --git a/i18n/en.pot b/i18n/en.pot index b19a9f94f4..9db2b4dd84 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-06T09:56:48.432Z\n" -"PO-Revision-Date: 2026-08-06T09:56:48.432Z\n" +"POT-Creation-Date: 2026-08-07T06:49:34.201Z\n" +"PO-Revision-Date: 2026-08-07T06:49:34.201Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts index 49b34132fe..a698f8740c 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -1,10 +1,8 @@ export { CUSTOM_LABEL_FIELDS, - resolveLabel, + resolveCustomLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { useProgramLabel, useStageLabel, useTrackedEntityTypeLabel } from './useLabel'; +export { applyCustomTerminology } from './applyCustomTerminology'; +export { bootstrapCustomTerminology } from './bootstrapCustomTerminology'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts deleted file mode 100644 index c733c2e662..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { programCollection, trackedEntityTypesCollection } from '../../../metaDataMemoryStores'; -import { resolveLabel } from './customLabels'; -import type { CustomLabelKey, LabelOptions } from './customLabels'; - -type ProgramOptions = LabelOptions & { programId?: string }; -type StageOptions = LabelOptions & { programId?: string, stageId?: string }; -type TrackedEntityTypeOptions = LabelOptions & { tetId?: string }; - -export const useProgramLabel = (key: CustomLabelKey, { programId, plural }: ProgramOptions = {}): string | undefined => { - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const id = programId ?? currentProgramId; - return useMemo( - () => resolveLabel(id ? programCollection.get(id)?.customLabels : undefined, key, { plural }), - [id, key, plural], - ); -}; - -export const useStageLabel = ( - key: CustomLabelKey, - { programId, stageId, plural }: StageOptions = {}, -): string | undefined => { - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const currentStageId = useSelector(({ currentSelections }: any) => currentSelections.stageId); - const pId = programId ?? currentProgramId; - const sId = stageId ?? currentStageId; - return useMemo(() => { - const program = pId ? programCollection.get(pId) : undefined; - const stage = program && sId ? program.getStage(sId) : undefined; - return resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - }, [pId, sId, key, plural]); -}; - -export const useTrackedEntityTypeLabel = ( - key: CustomLabelKey, - { tetId, plural }: TrackedEntityTypeOptions = {}, -): string | undefined => { - const currentTetId = useSelector(({ currentSelections }: any) => currentSelections.trackedEntityTypeId); - const id = tetId ?? currentTetId; - return useMemo( - () => resolveLabel(id ? trackedEntityTypesCollection.get(id)?.customLabels : undefined, key, { plural }), - [id, key, plural], - ); -}; diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index 627adbd3b3..e5a22c7396 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -19,13 +19,9 @@ export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { CUSTOM_LABEL_FIELDS, - resolveLabel, + resolveCustomLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + applyCustomTerminology, + bootstrapCustomTerminology, } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 00e7aca7aa..06898f4539 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,13 +41,9 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, CUSTOM_LABEL_FIELDS, - resolveLabel, + resolveCustomLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + applyCustomTerminology, + bootstrapCustomTerminology, } from './helpers'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; From 302cc2bc52a6fdb83ac410453fd1ce1bb62dcb1e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:15:44 +0000 Subject: [PATCH 07/24] feat: implement custom terminology handling with bootstrap --- i18n/en.pot | 4 +- .../customLabels/applyCustomTerminology.ts | 99 +++++++++++++++++++ .../bootstrapCustomTerminology.ts | 30 ++++++ .../helpers/customLabels/customLabels.ts | 99 ++++++++++++------- .../metaData/helpers/customLabels/index.ts | 1 + .../customLabels/resolveTerminologyContext.ts | 58 +++++++++++ src/declarations.d.ts | 3 + src/store/getStore.ts | 3 + 8 files changed, 257 insertions(+), 40 deletions(-) create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts diff --git a/i18n/en.pot b/i18n/en.pot index 9db2b4dd84..7cb5db531b 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-07T06:49:34.201Z\n" -"PO-Revision-Date: 2026-08-07T06:49:34.201Z\n" +"POT-Creation-Date: 2026-08-07T08:15:45.211Z\n" +"PO-Revision-Date: 2026-08-07T08:15:45.213Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts new file mode 100644 index 0000000000..1608da0285 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -0,0 +1,99 @@ +import i18n from '@dhis2/d2-i18n'; +import { programCollection, trackedEntityTypesCollection } from '../../../metaDataMemoryStores'; +import { CUSTOM_LABEL_FIELDS, resolveCustomLabel } from './customLabels'; +import type { CustomLabelKey, CustomLabels, CustomLabelField } from './customLabels'; + +export type TerminologyContext = { + programId?: string, + stageId?: string, + trackedEntityTypeId?: string, +}; + +type TermEntry = { + key: CustomLabelKey, + plural: boolean, + english: string, +}; + +// Derive the flat list of match candidates from CUSTOM_LABEL_FIELDS (single source +// of truth). Includes each form's English word plus any aliases (e.g. "stage" for +// programStage.singular). Sorted longest-first so multi-word forms are tried before +// their sub-strings — the combined regex's alternation then respects that order. +const TERM_ENTRIES: ReadonlyArray = ( + Object.entries(CUSTOM_LABEL_FIELDS) as ReadonlyArray<[CustomLabelKey, CustomLabelField]> +).flatMap(([key, forms]) => { + const out: TermEntry[] = []; + const addForm = (form: { english: string, aliases?: ReadonlyArray }, plural: boolean) => { + out.push({ key, plural, english: form.english }); + (form.aliases ?? []).forEach(alias => out.push({ key, plural, english: alias })); + }; + if (forms.plural) addForm(forms.plural, true); + addForm(forms.singular, false); + return out; +}).sort((a, b) => b.english.length - a.english.length); + +const escapeRegExp = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const COMBINED_PATTERN = new RegExp( + `\\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\\b`, + 'gi', +); + +const findEntry = (match: string): TermEntry | undefined => { + const lower = match.toLowerCase(); + return TERM_ENTRIES.find(entry => entry.english === lower); +}; + +const preserveCase = (match: string, replacement: string, locale: string): string => { + if (match.length > 1 && match === match.toLocaleUpperCase(locale)) { + return replacement.toLocaleUpperCase(locale); + } + if (match[0] === match[0].toLocaleUpperCase(locale)) { + return replacement.charAt(0).toLocaleUpperCase(locale) + replacement.slice(1); + } + return replacement; +}; + +const getLabelSources = ({ + programId, + stageId, + trackedEntityTypeId, +}: TerminologyContext): Array => { + const program = programId ? programCollection.get(programId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + const tet = trackedEntityTypeId ? trackedEntityTypesCollection.get(trackedEntityTypeId) : undefined; + // Precedence: stage overrides program overrides TET. + return [stage?.customLabels, program?.customLabels, tet?.customLabels]; +}; + +/** + * Substitute DHIS2 terminology (enrollment, event, note, relationship, ...) in a + * translated string with per-program custom labels when configured. Case in the + * source string is preserved on the substituted term. Locale-aware via `i18n.language`. + * + * @example + * applyCustomTerminology(i18n.t('Write a note about this enrollment'), { programId }) + */ +export const applyCustomTerminology = ( + translatedText: string, + context: TerminologyContext = {}, +): string => { + // Defensive: t() can return non-string values (arrays/objects when + // returnObjects: true, undefined for missing keys with certain configs). + // Only strings go through the substitution pipeline; everything else is + // returned as-is so callers see the original i18next output unchanged. + if (typeof translatedText !== 'string' || !translatedText) return translatedText; + const { programId, stageId, trackedEntityTypeId } = context; + if (!programId && !stageId && !trackedEntityTypeId) return translatedText; + + const sources = getLabelSources(context); + const locale = i18n.language || 'en'; + + return translatedText.replace(COMBINED_PATTERN, (match) => { + const entry = findEntry(match); + if (!entry) return match; + const custom = resolveCustomLabel(sources, entry.key, { plural: entry.plural }); + if (!custom) return match; + return preserveCase(match, custom, locale); + }); +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts new file mode 100644 index 0000000000..12eba2b70c --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts @@ -0,0 +1,30 @@ +import i18n from '@dhis2/d2-i18n'; +import { applyCustomTerminology } from './applyCustomTerminology'; +import { resolveTerminologyContext } from './resolveTerminologyContext'; + +type StoreLike = { getState: () => unknown }; + +let bootstrapped = false; + +/** + * Wraps i18n.t so every call passes its result through applyCustomTerminology, + * substituting DHIS2 terms with the current program's custom labels. Program / + * stage / tracked entity type ids are resolved per call via + * resolveTerminologyContext (URL first, then Redux domain state). + * + * Callers can opt out per call with i18n.t(key, { postProcess: false }) — for + * strings that mention DHIS2 terms in the everyday sense. + * + * Call once at app bootstrap. + */ +export const bootstrapCustomTerminology = (store: StoreLike) => { + if (bootstrapped) return; + bootstrapped = true; + + const originalT = i18n.t.bind(i18n); + i18n.t = (key: string, options?: any) => { + const translated = originalT(key, options); + if (options?.postProcess === false) return translated; + return applyCustomTerminology(translated, resolveTerminologyContext(store)); + }; +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 0366c64f64..56670d7c35 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -1,20 +1,64 @@ -import { capitalizeFirstLetter } from 'capture-core-utils/string'; +// Owns the shape and lookup of program/stage/tracked-entity-type custom labels: +// - CUSTOM_LABEL_FIELDS is the single source of truth: for each DHIS2 term it +// names the API field to read (per singular/plural), the English word to +// look for in translated strings, and any aliases. +// - extractCustomLabels reads them off the API cache when domain objects are +// built (ProgramFactory / ProgramStageFactory / TrackedEntityTypeFactory). +// - resolveCustomLabel picks a label from those sources at substitution time +// (called by the postProcessor in applyCustomTerminology). +// Previously also exported resolveLabel/getProgramLabel/useProgramLabel which +// forced capitalization at every call site; that pattern is gone since the +// postProcessor now handles case at the point of substitution. -type CustomLabelField = { - singular?: string, - plural?: string, +export type CustomLabelForm = { + field: string, + english: string, + aliases?: ReadonlyArray, +}; + +export type CustomLabelField = { + singular: CustomLabelForm, + plural?: CustomLabelForm, }; export const CUSTOM_LABEL_FIELDS = { - enrollment: { singular: 'displayEnrollmentLabel', plural: 'displayEnrollmentsLabel' }, - followUp: { singular: 'displayFollowUpLabel' }, - orgUnit: { singular: 'displayOrgUnitLabel' }, - note: { singular: 'displayNoteLabel', plural: 'displayNotesLabel' }, - relationship: { singular: 'displayRelationshipLabel', plural: 'displayRelationshipsLabel' }, - attribute: { singular: 'displayTrackedEntityAttributeLabel', plural: 'displayTrackedEntityAttributesLabel' }, - programStage: { singular: 'displayProgramStageLabel', plural: 'displayProgramStagesLabel' }, - event: { singular: 'displayEventLabel', plural: 'displayEventsLabel' }, - trackedEntityType: { singular: 'displayTrackedEntityTypeLabel', plural: 'displayTrackedEntityTypesLabel' }, + enrollment: { + singular: { field: 'displayEnrollmentLabel', english: 'enrollment' }, + plural: { field: 'displayEnrollmentsLabel', english: 'enrollments' }, + }, + event: { + singular: { field: 'displayEventLabel', english: 'event' }, + plural: { field: 'displayEventsLabel', english: 'events' }, + }, + note: { + singular: { field: 'displayNoteLabel', english: 'note' }, + plural: { field: 'displayNotesLabel', english: 'notes' }, + }, + relationship: { + singular: { field: 'displayRelationshipLabel', english: 'relationship' }, + plural: { field: 'displayRelationshipsLabel', english: 'relationships' }, + }, + attribute: { + singular: { field: 'displayTrackedEntityAttributeLabel', english: 'attribute' }, + plural: { field: 'displayTrackedEntityAttributesLabel', english: 'attributes' }, + }, + programStage: { + singular: { field: 'displayProgramStageLabel', english: 'program stage', aliases: ['stage'] }, + plural: { field: 'displayProgramStagesLabel', english: 'program stages', aliases: ['stages'] }, + }, + // API only exposes a singular custom label for orgUnit; we reuse it for the + // plural form so "organisation units" resolves to the same admin-set string. + orgUnit: { + singular: { field: 'displayOrgUnitLabel', english: 'organisation unit' }, + plural: { field: 'displayOrgUnitLabel', english: 'organisation units' }, + }, + trackedEntityType: { + singular: { field: 'displayTrackedEntityTypeLabel', english: 'tracked entity' }, + plural: { field: 'displayTrackedEntityTypesLabel', english: 'tracked entities' }, + }, + followUp: { + singular: { field: 'displayFollowUpLabel', english: 'follow-up' }, + }, } as const satisfies { [key: string]: CustomLabelField }; export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; @@ -24,7 +68,7 @@ export type LabelOptions = { plural?: boolean }; const ALL_FIELDS: ReadonlyArray = Array.from( new Set( Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.singular, term.plural]) + .flatMap((term: CustomLabelField) => [term.singular.field, term.plural?.field]) .filter((field): field is string => Boolean(field)), ), ); @@ -42,34 +86,13 @@ export const extractCustomLabels = (cached: Record): CustomLabe type LabelSource = CustomLabels | undefined | null; -export const resolveLabel = ( +export const resolveCustomLabel = ( sources: LabelSource | Array, key: CustomLabelKey, { plural = false }: LabelOptions = {}, ): string | undefined => { const term: CustomLabelField = CUSTOM_LABEL_FIELDS[key]; const list = Array.isArray(sources) ? sources : [sources]; - const pick = (field?: string) => (field ? list.find(source => source?.[field])?.[field] : undefined); - - const field = plural && term.plural ? term.plural : term.singular; - const value = pick(field); - return value ? capitalizeFirstLetter(value) : value; + const form = plural && term.plural ? term.plural : term.singular; + return list.find(source => source?.[form.field])?.[form.field]; }; - -type WithLabels = { customLabels?: CustomLabels } | undefined | null; - -export const getProgramLabel = (program: WithLabels, key: CustomLabelKey, options?: LabelOptions): string | undefined => - resolveLabel(program?.customLabels, key, options); - -export const getStageLabel = ( - stage: WithLabels, - program: WithLabels, - key: CustomLabelKey, - options?: LabelOptions, -): string | undefined => resolveLabel([stage?.customLabels, program?.customLabels], key, options); - -export const getTrackedEntityTypeLabel = ( - trackedEntityType: WithLabels, - key: CustomLabelKey, - options?: LabelOptions, -): string | undefined => resolveLabel(trackedEntityType?.customLabels, key, options); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts index a698f8740c..06a2a4cd5d 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -5,4 +5,5 @@ export { } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; export { applyCustomTerminology } from './applyCustomTerminology'; +export type { TerminologyContext } from './applyCustomTerminology'; export { bootstrapCustomTerminology } from './bootstrapCustomTerminology'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts new file mode 100644 index 0000000000..a882d38430 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts @@ -0,0 +1,58 @@ +import { getLocationQuery } from '../../../utils/routing'; +import type { TerminologyContext } from './applyCustomTerminology'; + +type StoreLike = { getState: () => unknown }; + +type DomainState = { + viewEventPage?: { + loadedValues?: { + eventContainer?: { event?: { program?: string, programStage?: string } }, + }, + }, + enrollmentDomain?: { + enrollment?: { program?: string }, + }, +}; + +/** + * Layered resolution of the "current view's program" for terminology substitution. + * 1. URL query — most program-scoped pages carry programId directly (enrollment + * dashboard, working lists, new enrollment, etc.). + * 2. Redux domain state — pages that carry only entity ids (event edit's eventId, + * viewEvent's viewEventId, TEI dashboard's teiId) resolve program via the + * loaded entity. + * 3. Nothing — English fallback. Never touches state.currentSelections, which is + * the top-nav scope filter and can diverge from the entity actually on screen. + */ +export const resolveTerminologyContext = (store: StoreLike): TerminologyContext => { + const query = getLocationQuery(); + + // Layer 1: URL + if (query.programId) { + return { + programId: query.programId, + stageId: query.stageId ?? query.programStageId, + trackedEntityTypeId: query.trackedEntityTypeId, + }; + } + + // Layer 2: Redux domain state + const state = (store.getState() ?? {}) as DomainState; + + if (query.eventId || query.viewEventId) { + const event = state.viewEventPage?.loadedValues?.eventContainer?.event; + if (event?.program) { + return { programId: event.program, stageId: event.programStage }; + } + } + + if (query.enrollmentId || query.teiId) { + const enrollment = state.enrollmentDomain?.enrollment; + if (enrollment?.program) { + return { programId: enrollment.program }; + } + } + + // Layer 3: no context — postProcessor will leave the string untouched. + return {}; +}; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 1cc6a18172..b3de0cd798 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -13,6 +13,9 @@ declare module 'src/core_modules/*'; declare module '@dhis2/d2-i18n' { const i18n: { t: (key: string, options?: any) => any; + // Added for applyCustomTerminology, which passes the active locale to + // toLocaleUpperCase/toLocaleLowerCase for correct casing (e.g. Turkish i/İ). + language: string; // Add other methods as needed }; export default i18n; diff --git a/src/store/getStore.ts b/src/store/getStore.ts index 699b66dbe6..ac3596982a 100644 --- a/src/store/getStore.ts +++ b/src/store/getStore.ts @@ -9,6 +9,7 @@ import { environments } from 'capture-core/constants/environments'; import { createOffline } from '@redux-offline/redux-offline'; import offlineConfig from '@redux-offline/redux-offline/lib/defaults'; import { getEffectReconciler, shouldDiscard, queueConfig } from 'capture-core/trackerOffline'; +import { bootstrapCustomTerminology } from 'capture-core/metaData/helpers/customLabels'; import { getPersistOptions } from './persist/persistOptionsGetter'; import { reducerDescriptions } from '../reducers/descriptions/trackerCapture.reducerDescriptions'; import { epics } from '../epics/trackerCapture.epics'; @@ -55,5 +56,7 @@ export async function getStore( epicMiddleware.run(epics); + bootstrapCustomTerminology(store); + return store; } From 823f4e11be65fcc4febbf6ac1f4a85b7f011ad10 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:45:51 +0000 Subject: [PATCH 08/24] fix: revert changes belonging to child branch --- i18n/en.pot | 4 ++-- .../components/WidgetEnrollment/hooks/useProgram.ts | 3 +-- .../helpers/customLabels/applyCustomTerminology.ts | 2 +- .../metaData/helpers/customLabels/customLabels.ts | 9 +++------ .../TrackedEntityType/TrackedEntityTypeFactory.ts | 8 ++------ .../programs/quickStoreOperations/storePrograms.ts | 3 --- .../quickStoreOperations/types/apiPrograms.types.ts | 3 --- .../capture-core/storageControllers/types/cache.types.ts | 3 --- 8 files changed, 9 insertions(+), 26 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7cb5db531b..602e2de115 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-07T08:15:45.211Z\n" -"PO-Revision-Date: 2026-08-07T08:15:45.213Z\n" +"POT-Creation-Date: 2026-08-07T08:45:53.252Z\n" +"PO-Revision-Date: 2026-08-07T08:45:53.252Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts index 1e2d99395a..b76d2e87ce 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -18,8 +18,7 @@ const baseFields = [ ]; const pluralFields = [ - 'displayEnrollmentsLabel,displayRelationshipsLabel,displayNotesLabel,' + - 'displayTrackedEntityAttributesLabel,displayProgramStagesLabel,displayEventsLabel', + 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel', ]; export const useProgram = (programId: string) => { diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts index 1608da0285..b5f95242e4 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -28,7 +28,7 @@ const TERM_ENTRIES: ReadonlyArray = ( (form.aliases ?? []).forEach(alias => out.push({ key, plural, english: alias })); }; if (forms.plural) addForm(forms.plural, true); - addForm(forms.singular, false); + if (forms.singular) addForm(forms.singular, false); return out; }).sort((a, b) => b.english.length - a.english.length); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 56670d7c35..8c15d0ad43 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -17,7 +17,7 @@ export type CustomLabelForm = { }; export type CustomLabelField = { - singular: CustomLabelForm, + singular?: CustomLabelForm, plural?: CustomLabelForm, }; @@ -32,15 +32,12 @@ export const CUSTOM_LABEL_FIELDS = { }, note: { singular: { field: 'displayNoteLabel', english: 'note' }, - plural: { field: 'displayNotesLabel', english: 'notes' }, }, relationship: { singular: { field: 'displayRelationshipLabel', english: 'relationship' }, - plural: { field: 'displayRelationshipsLabel', english: 'relationships' }, }, attribute: { singular: { field: 'displayTrackedEntityAttributeLabel', english: 'attribute' }, - plural: { field: 'displayTrackedEntityAttributesLabel', english: 'attributes' }, }, programStage: { singular: { field: 'displayProgramStageLabel', english: 'program stage', aliases: ['stage'] }, @@ -53,7 +50,6 @@ export const CUSTOM_LABEL_FIELDS = { plural: { field: 'displayOrgUnitLabel', english: 'organisation units' }, }, trackedEntityType: { - singular: { field: 'displayTrackedEntityTypeLabel', english: 'tracked entity' }, plural: { field: 'displayTrackedEntityTypesLabel', english: 'tracked entities' }, }, followUp: { @@ -68,7 +64,7 @@ export type LabelOptions = { plural?: boolean }; const ALL_FIELDS: ReadonlyArray = Array.from( new Set( Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.singular.field, term.plural?.field]) + .flatMap((term: CustomLabelField) => [term.singular?.field, term.plural?.field]) .filter((field): field is string => Boolean(field)), ), ); @@ -94,5 +90,6 @@ export const resolveCustomLabel = ( const term: CustomLabelField = CUSTOM_LABEL_FIELDS[key]; const list = Array.isArray(sources) ? sources : [sources]; const form = plural && term.plural ? term.plural : term.singular; + if (!form) return undefined; return list.find(source => source?.[form.field])?.[form.field]; }; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index e5c1359aae..7e5e9d5a91 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -84,14 +84,10 @@ export class TrackedEntityTypeFactory { const trackedEntityType = new TrackedEntityType((o) => { o.id = cachedType.id; o.access = cachedType.access; - const name = this._getTranslation( + o.name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - o.name = name; - o.customLabels = extractCustomLabels({ - ...cachedType, - displayTrackedEntityTypeLabel: name, - }); + o.customLabels = extractCustomLabels(cachedType); }); if (cachedType.trackedEntityTypeAttributes) { diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts index 48a9dddec6..7d972501b6 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -170,9 +170,6 @@ const baseProgramFields = [ const pluralProgramFields = [ 'displayEnrollmentsLabel', - 'displayRelationshipsLabel', - 'displayNotesLabel', - 'displayTrackedEntityAttributesLabel', 'displayProgramStagesLabel', 'displayEventsLabel', ]; diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts index 4849b10ab2..676c11b68a 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/types/apiPrograms.types.ts @@ -147,11 +147,8 @@ type apiProgram = { displayFollowUpLabel?: string | null, displayOrgUnitLabel?: string | null, displayRelationshipLabel?: string | null, - displayRelationshipsLabel?: string | null, displayNoteLabel?: string | null, - displayNotesLabel?: string | null, displayTrackedEntityAttributeLabel?: string | null, - displayTrackedEntityAttributesLabel?: string | null, displayProgramStageLabel?: string | null, displayProgramStagesLabel?: string | null, displayEventLabel?: string | null, diff --git a/src/core_modules/capture-core/storageControllers/types/cache.types.ts b/src/core_modules/capture-core/storageControllers/types/cache.types.ts index d903c0ab50..771a55c14b 100644 --- a/src/core_modules/capture-core/storageControllers/types/cache.types.ts +++ b/src/core_modules/capture-core/storageControllers/types/cache.types.ts @@ -215,11 +215,8 @@ export type CachedProgram = { displayFollowUpLabel?: string | null, displayOrgUnitLabel?: string | null, displayRelationshipLabel?: string | null, - displayRelationshipsLabel?: string | null, displayNoteLabel?: string | null, - displayNotesLabel?: string | null, displayTrackedEntityAttributeLabel?: string | null, - displayTrackedEntityAttributesLabel?: string | null, displayProgramStageLabel?: string | null, displayProgramStagesLabel?: string | null, displayEventLabel?: string | null, From 764e73bf66b16614b0ecc116a9acf34b493ee5f4 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:08:24 +0000 Subject: [PATCH 09/24] fix: sonar qube --- i18n/en.pot | 4 ++-- .../helpers/customLabels/applyCustomTerminology.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 602e2de115..d371813061 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-07T08:45:53.252Z\n" -"PO-Revision-Date: 2026-08-07T08:45:53.252Z\n" +"POT-Creation-Date: 2026-08-07T09:08:26.197Z\n" +"PO-Revision-Date: 2026-08-07T09:08:26.197Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts index b5f95242e4..3d6cd75751 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -32,10 +32,10 @@ const TERM_ENTRIES: ReadonlyArray = ( return out; }).sort((a, b) => b.english.length - a.english.length); -const escapeRegExp = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const escapeRegExp = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); const COMBINED_PATTERN = new RegExp( - `\\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\\b`, + String.raw`\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\b`, 'gi', ); @@ -48,7 +48,8 @@ const preserveCase = (match: string, replacement: string, locale: string): strin if (match.length > 1 && match === match.toLocaleUpperCase(locale)) { return replacement.toLocaleUpperCase(locale); } - if (match[0] === match[0].toLocaleUpperCase(locale)) { + const firstUpper = match.charAt(0).toLocaleUpperCase(locale); + if (match.startsWith(firstUpper)) { return replacement.charAt(0).toLocaleUpperCase(locale) + replacement.slice(1); } return replacement; From 2e19a93703b125ceeb3c38db3f2e157d95c56486 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:06:58 +0000 Subject: [PATCH 10/24] feat: code clean up --- i18n/en.pot | 4 ++-- .../metaData/helpers/customLabels/customLabels.ts | 1 + .../factory/TrackedEntityType/TrackedEntityTypeFactory.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3cf89134e2..0c4b285d6b 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-07T10:40:46.859Z\n" -"PO-Revision-Date: 2026-08-07T10:40:46.859Z\n" +"POT-Creation-Date: 2026-08-07T11:06:59.807Z\n" +"PO-Revision-Date: 2026-08-07T11:06:59.807Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 8c15d0ad43..a7de3b4643 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -50,6 +50,7 @@ export const CUSTOM_LABEL_FIELDS = { plural: { field: 'displayOrgUnitLabel', english: 'organisation units' }, }, trackedEntityType: { + singular: { field: 'displayTrackedEntityTypeLabel', english: 'tracked entity' }, plural: { field: 'displayTrackedEntityTypesLabel', english: 'tracked entities' }, }, followUp: { diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index 7e5e9d5a91..e5c1359aae 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -84,10 +84,14 @@ export class TrackedEntityTypeFactory { const trackedEntityType = new TrackedEntityType((o) => { o.id = cachedType.id; o.access = cachedType.access; - o.name = this._getTranslation( + const name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - o.customLabels = extractCustomLabels(cachedType); + o.name = name; + o.customLabels = extractCustomLabels({ + ...cachedType, + displayTrackedEntityTypeLabel: name, + }); }); if (cachedType.trackedEntityTypeAttributes) { From 0a0d90a1a4fd8efd103bd73526bba979834262ce Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:10:18 +0000 Subject: [PATCH 11/24] fix: type change --- i18n/en.pot | 4 ++-- .../metaData/helpers/customLabels/customLabels.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 0c4b285d6b..ec13175020 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-07T11:06:59.807Z\n" -"PO-Revision-Date: 2026-08-07T11:06:59.807Z\n" +"POT-Creation-Date: 2026-08-07T11:10:20.336Z\n" +"PO-Revision-Date: 2026-08-07T11:10:20.336Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index a7de3b4643..6dd96f7757 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -17,7 +17,7 @@ export type CustomLabelForm = { }; export type CustomLabelField = { - singular?: CustomLabelForm, + singular: CustomLabelForm, plural?: CustomLabelForm, }; @@ -65,7 +65,7 @@ export type LabelOptions = { plural?: boolean }; const ALL_FIELDS: ReadonlyArray = Array.from( new Set( Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.singular?.field, term.plural?.field]) + .flatMap((term: CustomLabelField) => [term.singular.field, term.plural?.field]) .filter((field): field is string => Boolean(field)), ), ); From 809aad61a6967f2d502c0cf13edf49ed752222ad Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:44:09 +0000 Subject: [PATCH 12/24] feat: update terminology --- i18n/en.pot | 49 +++++++++---------- .../Filters/FiltersRows.component.tsx | 2 +- .../NewEventWorkspace.component.tsx | 2 +- .../ProgramStageSelector.container.tsx | 2 +- .../TopBar/TopBar.component.tsx | 2 +- .../EnrollmentEditEvent/TopBar.container.tsx | 2 +- .../WidgetEnrollmentEventNew.container.tsx | 2 +- .../DataEntry/editEventDataEntry.actions.ts | 2 +- .../epics/editEventDataEntry.epics.ts | 2 +- .../viewEventDataEntry.actions.ts | 2 +- .../WidgetEventSchedule.container.tsx | 2 +- .../StageCreateNewButton.tsx | 2 +- .../Stages/Stages.component.tsx | 2 +- .../WidgetStagesAndEvents.component.tsx | 2 +- .../TrackedEntityType/TrackedEntityType.ts | 10 ---- .../TrackedEntityTypeFactory.ts | 12 +---- 16 files changed, 38 insertions(+), 59 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ec13175020..d69d57085a 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-07T11:10:20.336Z\n" -"PO-Revision-Date: 2026-08-07T11:10:20.336Z\n" +"POT-Creation-Date: 2026-08-07T13:44:12.014Z\n" +"PO-Revision-Date: 2026-08-07T13:44:12.017Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -688,8 +688,8 @@ msgstr "before or equal to" msgid "More filters" msgstr "More filters" -msgid "Stage filters" -msgstr "Stage filters" +msgid "Program stage filters" +msgstr "Program stage filters" msgid "Rows per page" msgstr "Rows per page" @@ -811,8 +811,8 @@ msgstr "There was an error loading the page" msgid "Program stage is invalid" msgstr "Program stage is invalid" -msgid "Stage not found" -msgstr "Stage not found" +msgid "Program stage not found" +msgstr "Program stage not found" msgid "Report" msgstr "Report" @@ -832,14 +832,14 @@ msgstr "You can't add any more {{ programStageName }} events" msgid "Cancel without saving" msgstr "Cancel without saving" -msgid "Choose a stage for a new event" -msgstr "Choose a stage for a new event" +msgid "Choose a program stage for a new event" +msgstr "Choose a program stage for a new event" msgid "Program Stages could not be loaded" msgstr "Program Stages could not be loaded" -msgid "Stage" -msgstr "Stage" +msgid "Program stage" +msgstr "Program stage" msgid "The category option is not valid for the selected organisation unit." msgstr "The category option is not valid for the selected organisation unit." @@ -1467,9 +1467,6 @@ msgstr "Add coordinates" msgid "Add area" msgstr "Add area" -msgid "Program stage not found" -msgstr "Program stage not found" - msgid "organisation unit could not be retrieved. Please try again later." msgstr "organisation unit could not be retrieved. Please try again later." @@ -1479,8 +1476,8 @@ msgstr "Saving to {{stageName}} for {{programName}} in {{orgUnitName}}" msgid "Saving to {{stageName}} for {{programName}}" msgstr "Saving to {{stageName}} for {{programName}}" -msgid "program or stage is invalid" -msgstr "program or stage is invalid" +msgid "Program or program stage is invalid" +msgstr "Program or program stage is invalid" msgid "Notes about this enrollment" msgstr "Notes about this enrollment" @@ -1497,8 +1494,8 @@ msgstr "Error" msgid "Warning" msgstr "Warning" -msgid "stage not found in rules execution" -msgstr "stage not found in rules execution" +msgid "Program stage not found in rules execution" +msgstr "Program stage not found in rules execution" msgid "Delete event" msgstr "Delete event" @@ -1593,9 +1590,6 @@ msgstr "Event notes" msgid "Write a note about this scheduled event" msgstr "Write a note about this scheduled event" -msgid "Program or stage is invalid" -msgstr "Program or stage is invalid" - msgid "Feedback" msgstr "Feedback" @@ -1754,8 +1748,8 @@ msgstr "Please enter a date" msgid "Please select a valid event" msgstr "Please select a valid event" -msgid "This stage can only have one event" -msgstr "This stage can only have one event" +msgid "This program stage can only have one event" +msgstr "This program stage can only have one event" msgid "New {{ eventName }} event" msgstr "New {{ eventName }} event" @@ -1804,11 +1798,11 @@ msgstr "{{ overdueEvents }} overdue" msgid "{{ scheduledEvents }} scheduled" msgstr "{{ scheduledEvents }} scheduled" -msgid "No stages found in this program" -msgstr "No stages found in this program" +msgid "No program stages found in this program" +msgstr "No program stages found in this program" -msgid "Stages and Events" -msgstr "Stages and Events" +msgid "Program stages and Events" +msgstr "Program stages and Events" msgid "An error occurred while unlinking and deleting the event." msgstr "An error occurred while unlinking and deleting the event." @@ -2248,6 +2242,9 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" +msgid "..." +msgstr "..." + msgid "Program not found" msgstr "Program not found" diff --git a/src/core_modules/capture-core/components/ListView/Filters/FiltersRows.component.tsx b/src/core_modules/capture-core/components/ListView/Filters/FiltersRows.component.tsx index 97fb2d473f..fcb22ae905 100644 --- a/src/core_modules/capture-core/components/ListView/Filters/FiltersRows.component.tsx +++ b/src/core_modules/capture-core/components/ListView/Filters/FiltersRows.component.tsx @@ -84,7 +84,7 @@ export const FiltersRowsPlain = ({ <>
-
{i18n.t('Stage filters').toUpperCase()}
+
{i18n.t('Program stage filters').toUpperCase()}
item.additionalColumn)} diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/NewEventWorkspace/NewEventWorkspace.component.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/NewEventWorkspace/NewEventWorkspace.component.tsx index fd6f703bb5..6122ca58cb 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/NewEventWorkspace/NewEventWorkspace.component.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/NewEventWorkspace/NewEventWorkspace.component.tsx @@ -69,7 +69,7 @@ const NewEventWorkspacePlain = ({ if (!stage) { return renderWidget( -
{i18n.t('Stage not found')}
, +
{i18n.t('Program stage not found')}
, ); } diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/ProgramStageSelector/ProgramStageSelector.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/ProgramStageSelector/ProgramStageSelector.container.tsx index 10e33b8309..1a3442a42b 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/ProgramStageSelector/ProgramStageSelector.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/ProgramStageSelector/ProgramStageSelector.container.tsx @@ -103,7 +103,7 @@ export const ProgramStageSelector = ({ programId, orgUnitId, teiId, enrollmentId <> {program ? diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/TopBar.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/TopBar.container.tsx index d5dd0a2dc1..dc2357d1a3 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/TopBar.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/TopBar.container.tsx @@ -102,7 +102,7 @@ export const TopBar = ({ }, ]} selectedValue="alwaysPreselected" - title={i18n.t('Stage')} + title={i18n.t('Program stage')} isUserInteractionInProgress={isUserInteractionInProgress} /> {programStage && ( diff --git a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/WidgetEnrollmentEventNew.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/WidgetEnrollmentEventNew.container.tsx index 4e48426865..5c2a625856 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/WidgetEnrollmentEventNew.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollmentEventNew/WidgetEnrollmentEventNew.container.tsx @@ -30,7 +30,7 @@ export const WidgetEnrollmentEventNew = ({ if (!program || !stage || !(program instanceof TrackerProgram) || isError || !formFoundation) { return (
- {i18n.t('program or stage is invalid')} + {i18n.t('Program or program stage is invalid')}
); } diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/editEventDataEntry.actions.ts b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/editEventDataEntry.actions.ts index 6f4b8b9243..10d9a263fb 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/editEventDataEntry.actions.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/editEventDataEntry.actions.ts @@ -165,7 +165,7 @@ export const openEventForEditInDataEntry = ({ if (program instanceof TrackerProgram) { const stage = getStageFromEvent(eventContainer.event)?.stage; if (!stage) { - throw Error(i18n.t('stage not found in rules execution')); + throw Error(i18n.t('Program stage not found in rules execution')); } // TODO: Add attributeValues & enrollmentData effects = getApplicableRuleEffectsForTrackerProgram({ diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.ts b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.ts index cd0f06fee8..eee466d3b6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/DataEntry/epics/editEventDataEntry.epics.ts @@ -56,7 +56,7 @@ const runRulesForEditSingleEvent = async ({ : getStageFromEvent(event)?.stage; if (!stage) { - throw Error(i18n.t('stage not found in rules execution')); + throw Error(i18n.t('Program stage not found in rules execution')); } const foundation = stage.stageForm; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts index ed4ea79daa..8290beb2b5 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/viewEventDataEntry.actions.ts @@ -149,7 +149,7 @@ export const loadViewEventDataEntry = if (program instanceof TrackerProgram) { const stage = getStageFromEvent(eventContainer.event)?.stage; if (!stage) { - throw Error(i18n.t('stage not found in rules execution')); + throw Error(i18n.t('Program stage not found in rules execution')); } effects = getApplicableRuleEffectsForTrackerProgram({ diff --git a/src/core_modules/capture-core/components/WidgetEventSchedule/WidgetEventSchedule.container.tsx b/src/core_modules/capture-core/components/WidgetEventSchedule/WidgetEventSchedule.container.tsx index 2b99d00290..7803a97d33 100644 --- a/src/core_modules/capture-core/components/WidgetEventSchedule/WidgetEventSchedule.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventSchedule/WidgetEventSchedule.container.tsx @@ -181,7 +181,7 @@ export const WidgetEventSchedule = ({ if (!program || !stage || !(program instanceof TrackerProgram) || !programStageScheduleConfig) { return (
- {i18n.t('Program or stage is invalid')} + {i18n.t('Program or program stage is invalid')}
); } diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx index 891650a162..f1bd8bea3c 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx @@ -31,7 +31,7 @@ export const StageCreateNewButton = ({ if (!repeatable && eventCount > 0) { return { isDisabled: true, - tooltipContent: i18n.t('This stage can only have one event'), + tooltipContent: i18n.t('This program stage can only have one event'), }; } return { diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx index 6ee363c119..bca2944634 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx @@ -52,7 +52,7 @@ export const StagesPlain = ({ if (!readableStages.length) { return (

- {i18n.t('No stages found in this program')} + {i18n.t('No program stages found in this program')}

); } diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx index 6a410d71ff..c17deb7e4c 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -44,7 +44,7 @@ const WidgetStagesAndEventsPlain = ({ - {i18n.t('Stages and Events')} + {i18n.t('Program stages and Events')} {showWidgetBadge && (
; _searchGroups!: Array; - _customLabels!: CustomLabels; constructor(initFn: ((_this: TrackedEntityType) => void) | null) { this._attributes = []; - this._customLabels = {}; initFn && isFunction(initFn) && initFn(this); } @@ -64,11 +61,4 @@ export class TrackedEntityType { get attributes(): Array { return this._attributes; } - - set customLabels(customLabels: CustomLabels) { - this._customLabels = customLabels; - } - get customLabels(): CustomLabels { - return this._customLabels; - } } diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index e5c1359aae..726731a7f2 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -1,8 +1,5 @@ /* eslint-disable no-underscore-dangle */ -import { - TrackedEntityType, - extractCustomLabels, -} from '../../../../metaData'; +import { TrackedEntityType } from '../../../../metaData'; import { DataElementFactory } from './DataElementFactory'; import { TeiRegistrationFactory } from './TeiRegistrationFactory'; import { SearchGroupFactory } from '../../../common/factory'; @@ -84,14 +81,9 @@ export class TrackedEntityTypeFactory { const trackedEntityType = new TrackedEntityType((o) => { o.id = cachedType.id; o.access = cachedType.access; - const name = this._getTranslation( + o.name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - o.name = name; - o.customLabels = extractCustomLabels({ - ...cachedType, - displayTrackedEntityTypeLabel: name, - }); }); if (cachedType.trackedEntityTypeAttributes) { From 54d8332969de29ecd43896b34f54c2626d218e6b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:48:07 +0000 Subject: [PATCH 13/24] feat: enhance API program fields with custom terminology support --- i18n/en.pot | 4 +- .../WidgetProfile/hooks/useApiProgram.ts | 65 +++++++++++-------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d69d57085a..e29a211f1b 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-07T13:44:12.014Z\n" -"PO-Revision-Date: 2026-08-07T13:44:12.017Z\n" +"POT-Creation-Date: 2026-08-07T13:48:09.545Z\n" +"PO-Revision-Date: 2026-08-07T13:48:09.545Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts index ada6c9dde0..95a875dbe6 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts @@ -1,34 +1,45 @@ import { useMemo } from 'react'; import { useDataQuery } from '@dhis2/app-runtime'; +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; -const fields = - 'id,version,displayName,displayShortName,description,programType,style,minAttributesRequiredToSearch,' + - 'enrollmentDateLabel,incidentDateLabel,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture,' + - 'displayIncidentDate,access[*],' + - 'dataEntryForm[id,htmlCode],' + - 'categoryCombo[id,displayName,isDefault,categories[id,displayName]],' + - 'programSections[id,displayFormName,displayDescription,sortOrder,trackedEntityAttributes],' + - 'programRuleVariables[id,displayName,programRuleVariableSourceType,valueType,program[id],' + - 'programStage[id],dataElement[id],trackedEntityAttribute[id],useCodeForOptionSet],' + - 'programStages[id,access,autoGenerateEvent,openAfterEnrollment,generatedByEnrollmentDate,' + - 'reportDateToUse,minDaysFromStart,displayName,description,executionDateLabel,formType,featureType,' + - 'validationStrategy,enableUserAssignment,style,' + +const baseTrackedEntityTypeFields = + 'id,access,displayName,allowAuditLog,minAttributesRequiredToSearch,featureType,' + + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + + 'translations[property,locale,value]'; + +const pluralTrackedEntityTypeFields = 'displayTrackedEntityTypesLabel'; + +const buildFields = (includePluralLabels: boolean) => { + const trackedEntityTypeFields = includePluralLabels + ? `${baseTrackedEntityTypeFields},${pluralTrackedEntityTypeFields}` + : baseTrackedEntityTypeFields; + return 'id,version,displayName,displayShortName,description,programType,style,minAttributesRequiredToSearch,' + + 'enrollmentDateLabel,incidentDateLabel,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture,' + + 'displayIncidentDate,access[*],' + 'dataEntryForm[id,htmlCode],' + - 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]],' + - 'programStageDataElements[compulsory,displayInReports,renderOptionsAsRadio,allowFutureDate,' + - 'renderType[*],dataElement[id,displayName,displayShortName,displayFormName,valueType,' + - 'translations[*],description,optionSetValue,style,optionSet[id,displayName,version,valueType,' + - 'options[id,displayName,code,style, translations]]]]],' + - 'programTrackedEntityAttributes[trackedEntityAttribute[id,displayName,displayShortName,displayFormName,' + - 'displayDescription,valueType,optionSetValue,unique,orgunitScope,pattern,translations[property,locale,value],' + - 'optionSet[id,displayName,version,valueType,options[id,displayName,name,code,style,translations]]],' + - 'displayInList,searchable,mandatory,renderOptionsAsRadio,allowFutureDate],' + - 'trackedEntityType[id,access,displayName,allowAuditLog,minAttributesRequiredToSearch,featureType,' + - 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + - 'translations[property,locale,value]],' + - 'userRoles[id,displayName]'; + 'categoryCombo[id,displayName,isDefault,categories[id,displayName]],' + + 'programSections[id,displayFormName,displayDescription,sortOrder,trackedEntityAttributes],' + + 'programRuleVariables[id,displayName,programRuleVariableSourceType,valueType,program[id],' + + 'programStage[id],dataElement[id],trackedEntityAttribute[id],useCodeForOptionSet],' + + 'programStages[id,access,autoGenerateEvent,openAfterEnrollment,generatedByEnrollmentDate,' + + 'reportDateToUse,minDaysFromStart,displayName,description,executionDateLabel,formType,featureType,' + + 'validationStrategy,enableUserAssignment,style,' + + 'dataEntryForm[id,htmlCode],' + + 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]],' + + 'programStageDataElements[compulsory,displayInReports,renderOptionsAsRadio,allowFutureDate,' + + 'renderType[*],dataElement[id,displayName,displayShortName,displayFormName,valueType,' + + 'translations[*],description,optionSetValue,style,optionSet[id,displayName,version,valueType,' + + 'options[id,displayName,code,style, translations]]]]],' + + 'programTrackedEntityAttributes[trackedEntityAttribute[id,displayName,displayShortName,displayFormName,' + + 'displayDescription,valueType,optionSetValue,unique,orgunitScope,pattern,translations[property,locale,value],' + + 'optionSet[id,displayName,version,valueType,options[id,displayName,name,code,style,translations]]],' + + 'displayInList,searchable,mandatory,renderOptionsAsRadio,allowFutureDate],' + + `trackedEntityType[${trackedEntityTypeFields}],` + + 'userRoles[id,displayName]'; +}; export const useApiProgram = (programId: string) => { + const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const { error, loading, data } = useDataQuery( useMemo( () => ({ @@ -36,11 +47,11 @@ export const useApiProgram = (programId: string) => { resource: 'programs', id: programId, params: { - fields, + fields: buildFields(includePluralLabels), }, }, }), - [programId], + [programId, includePluralLabels], ), ); From 45eb553f337c34ecb7c377b26659a67f81ab721c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:57:53 +0000 Subject: [PATCH 14/24] feat: clean up --- i18n/en.pot | 7 ++--- .../customLabels/applyCustomTerminology.ts | 28 +++---------------- .../bootstrapCustomTerminology.ts | 16 ++--------- .../helpers/customLabels/customLabels.ts | 22 ++------------- .../customLabels/resolveTerminologyContext.ts | 18 ++---------- 5 files changed, 12 insertions(+), 79 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e29a211f1b..21f6410dbd 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-07T13:48:09.545Z\n" -"PO-Revision-Date: 2026-08-07T13:48:09.545Z\n" +"POT-Creation-Date: 2026-08-07T13:57:55.501Z\n" +"PO-Revision-Date: 2026-08-07T13:57:55.501Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -2242,9 +2242,6 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" -msgid "..." -msgstr "..." - msgid "Program not found" msgstr "Program not found" diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts index 3d6cd75751..e431413630 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -1,12 +1,11 @@ import i18n from '@dhis2/d2-i18n'; -import { programCollection, trackedEntityTypesCollection } from '../../../metaDataMemoryStores'; +import { programCollection } from '../../../metaDataMemoryStores'; import { CUSTOM_LABEL_FIELDS, resolveCustomLabel } from './customLabels'; import type { CustomLabelKey, CustomLabels, CustomLabelField } from './customLabels'; export type TerminologyContext = { programId?: string, stageId?: string, - trackedEntityTypeId?: string, }; type TermEntry = { @@ -15,10 +14,6 @@ type TermEntry = { english: string, }; -// Derive the flat list of match candidates from CUSTOM_LABEL_FIELDS (single source -// of truth). Includes each form's English word plus any aliases (e.g. "stage" for -// programStage.singular). Sorted longest-first so multi-word forms are tried before -// their sub-strings — the combined regex's alternation then respects that order. const TERM_ENTRIES: ReadonlyArray = ( Object.entries(CUSTOM_LABEL_FIELDS) as ReadonlyArray<[CustomLabelKey, CustomLabelField]> ).flatMap(([key, forms]) => { @@ -58,34 +53,19 @@ const preserveCase = (match: string, replacement: string, locale: string): strin const getLabelSources = ({ programId, stageId, - trackedEntityTypeId, }: TerminologyContext): Array => { const program = programId ? programCollection.get(programId) : undefined; const stage = program && stageId ? program.getStage(stageId) : undefined; - const tet = trackedEntityTypeId ? trackedEntityTypesCollection.get(trackedEntityTypeId) : undefined; - // Precedence: stage overrides program overrides TET. - return [stage?.customLabels, program?.customLabels, tet?.customLabels]; + return [stage?.customLabels, program?.customLabels]; }; -/** - * Substitute DHIS2 terminology (enrollment, event, note, relationship, ...) in a - * translated string with per-program custom labels when configured. Case in the - * source string is preserved on the substituted term. Locale-aware via `i18n.language`. - * - * @example - * applyCustomTerminology(i18n.t('Write a note about this enrollment'), { programId }) - */ export const applyCustomTerminology = ( translatedText: string, context: TerminologyContext = {}, ): string => { - // Defensive: t() can return non-string values (arrays/objects when - // returnObjects: true, undefined for missing keys with certain configs). - // Only strings go through the substitution pipeline; everything else is - // returned as-is so callers see the original i18next output unchanged. if (typeof translatedText !== 'string' || !translatedText) return translatedText; - const { programId, stageId, trackedEntityTypeId } = context; - if (!programId && !stageId && !trackedEntityTypeId) return translatedText; + const { programId, stageId } = context; + if (!programId && !stageId) return translatedText; const sources = getLabelSources(context); const locale = i18n.language || 'en'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts index 12eba2b70c..67b79af692 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts @@ -2,29 +2,17 @@ import i18n from '@dhis2/d2-i18n'; import { applyCustomTerminology } from './applyCustomTerminology'; import { resolveTerminologyContext } from './resolveTerminologyContext'; -type StoreLike = { getState: () => unknown }; +type ReduxStore = { getState: () => unknown }; let bootstrapped = false; -/** - * Wraps i18n.t so every call passes its result through applyCustomTerminology, - * substituting DHIS2 terms with the current program's custom labels. Program / - * stage / tracked entity type ids are resolved per call via - * resolveTerminologyContext (URL first, then Redux domain state). - * - * Callers can opt out per call with i18n.t(key, { postProcess: false }) — for - * strings that mention DHIS2 terms in the everyday sense. - * - * Call once at app bootstrap. - */ -export const bootstrapCustomTerminology = (store: StoreLike) => { +export const bootstrapCustomTerminology = (store: ReduxStore) => { if (bootstrapped) return; bootstrapped = true; const originalT = i18n.t.bind(i18n); i18n.t = (key: string, options?: any) => { const translated = originalT(key, options); - if (options?.postProcess === false) return translated; return applyCustomTerminology(translated, resolveTerminologyContext(store)); }; }; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 6dd96f7757..530add6dea 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -1,15 +1,3 @@ -// Owns the shape and lookup of program/stage/tracked-entity-type custom labels: -// - CUSTOM_LABEL_FIELDS is the single source of truth: for each DHIS2 term it -// names the API field to read (per singular/plural), the English word to -// look for in translated strings, and any aliases. -// - extractCustomLabels reads them off the API cache when domain objects are -// built (ProgramFactory / ProgramStageFactory / TrackedEntityTypeFactory). -// - resolveCustomLabel picks a label from those sources at substitution time -// (called by the postProcessor in applyCustomTerminology). -// Previously also exported resolveLabel/getProgramLabel/useProgramLabel which -// forced capitalization at every call site; that pattern is gone since the -// postProcessor now handles case at the point of substitution. - export type CustomLabelForm = { field: string, english: string, @@ -40,19 +28,13 @@ export const CUSTOM_LABEL_FIELDS = { singular: { field: 'displayTrackedEntityAttributeLabel', english: 'attribute' }, }, programStage: { - singular: { field: 'displayProgramStageLabel', english: 'program stage', aliases: ['stage'] }, - plural: { field: 'displayProgramStagesLabel', english: 'program stages', aliases: ['stages'] }, + singular: { field: 'displayProgramStageLabel', english: 'program stage' }, + plural: { field: 'displayProgramStagesLabel', english: 'program stages' }, }, - // API only exposes a singular custom label for orgUnit; we reuse it for the - // plural form so "organisation units" resolves to the same admin-set string. orgUnit: { singular: { field: 'displayOrgUnitLabel', english: 'organisation unit' }, plural: { field: 'displayOrgUnitLabel', english: 'organisation units' }, }, - trackedEntityType: { - singular: { field: 'displayTrackedEntityTypeLabel', english: 'tracked entity' }, - plural: { field: 'displayTrackedEntityTypesLabel', english: 'tracked entities' }, - }, followUp: { singular: { field: 'displayFollowUpLabel', english: 'follow-up' }, }, diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts index a882d38430..ada547f6c1 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts @@ -1,7 +1,7 @@ import { getLocationQuery } from '../../../utils/routing'; import type { TerminologyContext } from './applyCustomTerminology'; -type StoreLike = { getState: () => unknown }; +type ReduxStore = { getState: () => unknown }; type DomainState = { viewEventPage?: { @@ -14,29 +14,16 @@ type DomainState = { }, }; -/** - * Layered resolution of the "current view's program" for terminology substitution. - * 1. URL query — most program-scoped pages carry programId directly (enrollment - * dashboard, working lists, new enrollment, etc.). - * 2. Redux domain state — pages that carry only entity ids (event edit's eventId, - * viewEvent's viewEventId, TEI dashboard's teiId) resolve program via the - * loaded entity. - * 3. Nothing — English fallback. Never touches state.currentSelections, which is - * the top-nav scope filter and can diverge from the entity actually on screen. - */ -export const resolveTerminologyContext = (store: StoreLike): TerminologyContext => { +export const resolveTerminologyContext = (store: ReduxStore): TerminologyContext => { const query = getLocationQuery(); - // Layer 1: URL if (query.programId) { return { programId: query.programId, stageId: query.stageId ?? query.programStageId, - trackedEntityTypeId: query.trackedEntityTypeId, }; } - // Layer 2: Redux domain state const state = (store.getState() ?? {}) as DomainState; if (query.eventId || query.viewEventId) { @@ -53,6 +40,5 @@ export const resolveTerminologyContext = (store: StoreLike): TerminologyContext } } - // Layer 3: no context — postProcessor will leave the string untouched. return {}; }; From de452bc429c93e2d8f71deb3cea13ac3382778cc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:19:54 +0000 Subject: [PATCH 15/24] feat: temp devin review --- i18n/en.pot | 30 ++++++++-------- .../NotesSection/NotesSection.component.tsx | 4 +-- .../RelationshipsSection.component.tsx | 4 +-- .../WidgetBreakingTheGlass.component.tsx | 12 +++---- .../Status/Status.component.tsx | 4 +-- .../constants/status.const.ts | 2 +- .../customLabels/applyCustomTerminology.ts | 19 +++++++++- .../bootstrapCustomTerminology.ts | 35 +++++++++++++++++-- .../customLabels/resolveTerminologyContext.ts | 8 ++--- src/declarations.d.ts | 2 -- 10 files changed, 80 insertions(+), 40 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 21f6410dbd..f31db02f11 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-07T13:57:55.501Z\n" -"PO-Revision-Date: 2026-08-07T13:57:55.501Z\n" +"POT-Creation-Date: 2026-08-12T11:19:55.588Z\n" +"PO-Revision-Date: 2026-08-12T11:19:55.588Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1306,22 +1306,12 @@ msgstr "No one is assigned to this event" msgid "Assign" msgstr "Assign" -msgid "This program is protected" -msgstr "This program is protected" - -msgid "Reason to check for enrollments" -msgstr "Reason to check for enrollments" - -msgid "" -"Describe the reason you are checking for enrollments in this protected " -"program" -msgstr "" -"Describe the reason you are checking for enrollments in this protected " -"program" - msgid "Check for enrollments" msgstr "Check for enrollments" +msgid "This program is protected" +msgstr "This program is protected" + msgid "" "You must provide a reason to check for enrollments in this protected " "program." @@ -1332,6 +1322,16 @@ msgstr "" msgid "All activity will be logged." msgstr "All activity will be logged." +msgid "Reason to check for enrollments" +msgstr "Reason to check for enrollments" + +msgid "" +"Describe the reason you are checking for enrollments in this protected " +"program" +msgstr "" +"Describe the reason you are checking for enrollments in this protected " +"program" + msgid "Unsaved changes" msgstr "Unsaved changes" diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/NotesSection/NotesSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/NotesSection/NotesSection.component.tsx index bcd0546089..aba080dd1b 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/NotesSection/NotesSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/NotesSection/NotesSection.component.tsx @@ -12,8 +12,6 @@ import type { PlainProps } from './NotesSection.types'; const LoadingNotes = withLoadingIndicator(null, props => ({ style: props.loadingIndicatorStyle }))(Notes); -const headerText = i18n.t('Notes'); - const getStyles = (theme: any) => ({ badge: { backgroundColor: theme.palette.grey.light, @@ -42,7 +40,7 @@ class NotesSectionPlain extends React.Component { return ( diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/RelationshipsSection/RelationshipsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/RelationshipsSection/RelationshipsSection.component.tsx index dd3189f132..b9f3302044 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/RelationshipsSection/RelationshipsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/RightColumn/RelationshipsSection/RelationshipsSection.component.tsx @@ -15,8 +15,6 @@ import type { PlainProps } from './RelationshipsSection.types'; const LoadingRelationships = withLoadingIndicator(null, props => ({ style: props.loadingIndicatorStyle }))(Relationships); -const headerText = i18n.t('Relationships'); - const getStyles = (theme: any) => ({ badge: { backgroundColor: theme.palette.grey.light, @@ -53,7 +51,7 @@ class RelationshipsSectionPlain extends React.Component { return ( diff --git a/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx b/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx index 79c8583d90..494b3c5ba1 100644 --- a/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx +++ b/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx @@ -23,10 +23,6 @@ const styles: Readonly = ({ typography }: any) => ({ }, }); -const noticeBoxTitle = i18n.t('This program is protected'); -const reasonHeader = i18n.t('Reason to check for enrollments'); -const reasonPlaceholder = i18n.t('Describe the reason you are checking for enrollments in this protected program'); - type Props = PlainProps & WithStyles; const WidgetBreakingTheGlassPlain = ({ @@ -52,15 +48,17 @@ const WidgetBreakingTheGlassPlain = ({ {i18n.t('Check for enrollments')}

- + {i18n.t('You must provide a reason to check for enrollments in this protected program.')} {' '} {i18n.t('All activity will be logged.')}
- {translatedStatus[status] ?? status} + {getTranslatedStatus()[status] ?? status} ); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts b/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts index 2b0bb8939f..a8e0e23d69 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts @@ -6,7 +6,7 @@ export const plainStatus = Object.freeze({ CANCELLED: 'CANCELLED', }); -export const translatedStatus = Object.freeze({ +export const getTranslatedStatus = () => ({ [plainStatus.ACTIVE]: i18n.t('Active'), [plainStatus.COMPLETED]: i18n.t('Completed'), [plainStatus.CANCELLED]: i18n.t('Cancelled'), diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts index e431413630..70002ff2fe 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -8,6 +8,15 @@ export type TerminologyContext = { stageId?: string, }; +// Unicode "non-characters" (U+FDD0 / U+FDD1) — the Unicode standard reserves +// these to never be used for text, so they never collide with real content. +// The bootstrap wrapper brackets every interpolated value with these markers +// so we can skip them here and avoid rewriting server-supplied names that +// happen to contain a token word (e.g. a stage named "Birth event"). +export const INTERPOLATION_OPEN = '﷐'; +export const INTERPOLATION_CLOSE = '﷑'; +const INTERPOLATION_PATTERN = new RegExp(`${INTERPOLATION_OPEN}(.*?)${INTERPOLATION_CLOSE}`, 'g'); + type TermEntry = { key: CustomLabelKey, plural: boolean, @@ -70,11 +79,19 @@ export const applyCustomTerminology = ( const sources = getLabelSources(context); const locale = i18n.language || 'en'; - return translatedText.replace(COMBINED_PATTERN, (match) => { + const substitute = (text: string): string => text.replace(COMBINED_PATTERN, (match) => { const entry = findEntry(match); if (!entry) return match; const custom = resolveCustomLabel(sources, entry.key, { plural: entry.plural }); if (!custom) return match; return preserveCase(match, custom, locale); }); + + // Split on sentinel-wrapped interpolation regions. String.split with a + // capturing group returns [outside, inside, outside, ...] — substitute + // only in the outside parts so server-supplied values pass through + // unchanged. If no sentinels are present, we get [translatedText] and + // just substitute the whole thing. + const parts = translatedText.split(INTERPOLATION_PATTERN); + return parts.map((part, i) => (i % 2 === 0 ? substitute(part) : part)).join(''); }; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts index 67b79af692..8635a28e58 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts @@ -1,9 +1,40 @@ import i18n from '@dhis2/d2-i18n'; -import { applyCustomTerminology } from './applyCustomTerminology'; +import { applyCustomTerminology, INTERPOLATION_OPEN, INTERPOLATION_CLOSE } from './applyCustomTerminology'; import { resolveTerminologyContext } from './resolveTerminologyContext'; type ReduxStore = { getState: () => unknown }; +const I18N_CONTROL_KEYS = new Set([ + 'context', + 'count', + 'defaultValue', + 'fallbackLng', + 'interpolation', + 'joinArrays', + 'keySeparator', + 'lng', + 'lngs', + 'ns', + 'nsSeparator', + 'postProcess', + 'replace', + 'returnDetails', + 'returnObjects', + 'skipInterpolation', +]); + +const wrapInterpolationValues = (options?: Record): Record | undefined => { + if (!options || typeof options !== 'object') return options; + const wrapped: Record = {}; + for (const key of Object.keys(options)) { + const value = options[key]; + wrapped[key] = typeof value === 'string' && !I18N_CONTROL_KEYS.has(key) + ? `${INTERPOLATION_OPEN}${value}${INTERPOLATION_CLOSE}` + : value; + } + return wrapped; +}; + let bootstrapped = false; export const bootstrapCustomTerminology = (store: ReduxStore) => { @@ -12,7 +43,7 @@ export const bootstrapCustomTerminology = (store: ReduxStore) => { const originalT = i18n.t.bind(i18n); i18n.t = (key: string, options?: any) => { - const translated = originalT(key, options); + const translated = originalT(key, wrapInterpolationValues(options)); return applyCustomTerminology(translated, resolveTerminologyContext(store)); }; }; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts index ada547f6c1..cb0540e42f 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts @@ -6,7 +6,7 @@ type ReduxStore = { getState: () => unknown }; type DomainState = { viewEventPage?: { loadedValues?: { - eventContainer?: { event?: { program?: string, programStage?: string } }, + eventContainer?: { event?: { programId?: string, programStageId?: string } }, }, }, enrollmentDomain?: { @@ -26,10 +26,10 @@ export const resolveTerminologyContext = (store: ReduxStore): TerminologyContext const state = (store.getState() ?? {}) as DomainState; - if (query.eventId || query.viewEventId) { + if (query.eventId) { const event = state.viewEventPage?.loadedValues?.eventContainer?.event; - if (event?.program) { - return { programId: event.program, stageId: event.programStage }; + if (event?.programId) { + return { programId: event.programId, stageId: event.programStageId }; } } diff --git a/src/declarations.d.ts b/src/declarations.d.ts index b3de0cd798..561b1762ee 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -13,8 +13,6 @@ declare module 'src/core_modules/*'; declare module '@dhis2/d2-i18n' { const i18n: { t: (key: string, options?: any) => any; - // Added for applyCustomTerminology, which passes the active locale to - // toLocaleUpperCase/toLocaleLowerCase for correct casing (e.g. Turkish i/İ). language: string; // Add other methods as needed }; From 6a8cd9c03d83319ee50323115b1a5fedc05f0a91 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:22:50 +0000 Subject: [PATCH 16/24] fix: revert single event infrastructure --- i18n/en.pot | 4 ++-- .../customLabels/resolveTerminologyContext.ts | 12 ------------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f31db02f11..9d63eb5c1c 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-12T11:19:55.588Z\n" -"PO-Revision-Date: 2026-08-12T11:19:55.588Z\n" +"POT-Creation-Date: 2026-08-12T11:22:51.758Z\n" +"PO-Revision-Date: 2026-08-12T11:22:51.758Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts index cb0540e42f..07c2156337 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts @@ -4,11 +4,6 @@ import type { TerminologyContext } from './applyCustomTerminology'; type ReduxStore = { getState: () => unknown }; type DomainState = { - viewEventPage?: { - loadedValues?: { - eventContainer?: { event?: { programId?: string, programStageId?: string } }, - }, - }, enrollmentDomain?: { enrollment?: { program?: string }, }, @@ -26,13 +21,6 @@ export const resolveTerminologyContext = (store: ReduxStore): TerminologyContext const state = (store.getState() ?? {}) as DomainState; - if (query.eventId) { - const event = state.viewEventPage?.loadedValues?.eventContainer?.event; - if (event?.programId) { - return { programId: event.programId, stageId: event.programStageId }; - } - } - if (query.enrollmentId || query.teiId) { const enrollment = state.enrollmentDomain?.enrollment; if (enrollment?.program) { From 56ac5566db46f5b70a486e6370026dbbf341908c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:20:38 +0000 Subject: [PATCH 17/24] feat: update terminology handling across components --- i18n/en.pot | 4 +- .../useTeiDisplayName.ts | 4 +- .../Relationships/Relationships.component.tsx | 7 +- .../WidgetProfile/hooks/useTeiDisplayName.ts | 6 +- .../utils/getDataEntryDetails.ts | 68 +++++++++---------- .../trackedEntityInstances/getDisplayName.ts | 4 +- 6 files changed, 44 insertions(+), 49 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9d63eb5c1c..d59d365cad 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-12T11:22:51.758Z\n" -"PO-Revision-Date: 2026-08-12T11:22:51.758Z\n" +"POT-Creation-Date: 2026-08-12T12:20:40.672Z\n" +"PO-Revision-Date: 2026-08-12T12:20:40.672Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useTeiDisplayName.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useTeiDisplayName.ts index 06813db015..b07e923acf 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useTeiDisplayName.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/useTeiDisplayName.ts @@ -6,8 +6,6 @@ import { getAttributesFromScopeId } from '../../../../metaData/helpers'; import type { DataElement } from '../../../../metaData/DataElement'; import { convertServerToClient, convertClientToView } from '../../../../converters'; -const DEFAULT_NAME = i18n.t('tracked entity instance'); - type Attribute = { valueType: string; attribute: string; @@ -39,7 +37,7 @@ const getTetAttributes = (attributes: Array, tetAttributes: Array, trackedEntityType: string, teiId: string) => { const tetAttributes = getAttributesFromScopeId(trackedEntityType); - if (!attributes || !tetAttributes) return teiId ?? DEFAULT_NAME; + if (!attributes || !tetAttributes) return teiId ?? i18n.t('tracked entity instance'); const teiNameDisplayInReports = getTetAttributesDisplayInReports(attributes, tetAttributes); if (teiNameDisplayInReports) return teiNameDisplayInReports; diff --git a/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx b/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx index 0762e0d941..7b8ccb42e9 100644 --- a/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx +++ b/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx @@ -63,8 +63,9 @@ const styles: Readonly = (theme: any) => ({ }, }); -const fromNames = { - PROGRAM_STAGE_INSTANCE: i18n.t('This event'), +const getFromName = (entityType: string) => { + if (entityType === 'PROGRAM_STAGE_INSTANCE') return i18n.t('This event'); + return undefined; }; type PlainProps = { @@ -105,7 +106,7 @@ class RelationshipsPlain extends React.Component { const { onRenderConnectedEntity } = this.props; if (entity.id === this.props.currentEntityId) { - return fromNames[entity.type]; + return getFromName(entity.type); } return onRenderConnectedEntity ? onRenderConnectedEntity(entity) : entity.name; diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts index 0b8f9ec300..dec5ad533c 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts @@ -2,8 +2,6 @@ import { useMemo } from 'react'; import i18n from '@dhis2/d2-i18n'; import { convertClientToView } from '../DataEntry'; -const DEFAULT_NAME = i18n.t('tracked entity instance'); - type TeiAttribute = { attribute: string; value?: string; @@ -49,7 +47,7 @@ const deriveTeiName = ( tetAttributes: TetAttribute[], teiId?: string, ) => { - if (!attributes || !tetAttributes) return teiId ?? DEFAULT_NAME; + if (!attributes || !tetAttributes) return teiId ?? i18n.t('tracked entity instance'); const teiNameDisplayInList = getTetAttributesDisplayInList(attributes, tetAttributes as TetAttribute[]); if (teiNameDisplayInList) return teiNameDisplayInList; @@ -57,7 +55,7 @@ const deriveTeiName = ( const teiName = getTetAttributes(attributes, tetAttributes); if (teiName) return teiName; - return teiId ?? DEFAULT_NAME; + return teiId ?? i18n.t('tracked entity instance'); }; export const useTeiDisplayName = ( diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getDataEntryDetails.ts b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getDataEntryDetails.ts index 892b22a4a0..7d5c10dbf1 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getDataEntryDetails.ts +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/utils/getDataEntryDetails.ts @@ -13,42 +13,42 @@ export const Placements = { BOTTOM: 'BOTTOM', }; -const StatusLabels = { - ACTIVE: i18n.t('Active'), - COMPLETED: i18n.t('Completed'), - CANCELLED: i18n.t('Cancelled'), - SCHEDULE: i18n.t('Scheduled'), -}; +export const getDataEntryDetails = (linkedEvent: LinkedEvent, formFoundation: RenderFoundation) => { + const statusLabels: Record = { + ACTIVE: i18n.t('Active'), + COMPLETED: i18n.t('Completed'), + CANCELLED: i18n.t('Cancelled'), + SCHEDULE: i18n.t('Scheduled'), + }; -const DataEntryFieldsToInclude = { - occurredAt: { - apiKey: 'occurredAt', - type: dataElementTypes.DATE, - placement: Placements.TOP, - }, - scheduledAt: { - apiKey: 'scheduledAt', - type: dataElementTypes.DATE, - placement: Placements.TOP, - }, - orgUnit: { - apiKey: 'orgUnit', - type: dataElementTypes.ORGANISATION_UNIT, - placement: Placements.TOP, - label: i18n.t('Organisation unit'), - convertFn: (orgUnitId: string) => React.createElement(TooltipOrgUnit, { orgUnitId }), - }, - status: { - apiKey: 'status', - type: dataElementTypes.TEXT, - placement: Placements.BOTTOM, - label: i18n.t('Status'), - convertFn: (value: keyof typeof StatusLabels) => StatusLabels[value], - }, -}; + const dataEntryFieldsToInclude = { + occurredAt: { + apiKey: 'occurredAt', + type: dataElementTypes.DATE, + placement: Placements.TOP, + }, + scheduledAt: { + apiKey: 'scheduledAt', + type: dataElementTypes.DATE, + placement: Placements.TOP, + }, + orgUnit: { + apiKey: 'orgUnit', + type: dataElementTypes.ORGANISATION_UNIT, + placement: Placements.TOP, + label: i18n.t('Organisation unit'), + convertFn: (orgUnitId: string) => React.createElement(TooltipOrgUnit, { orgUnitId }), + }, + status: { + apiKey: 'status', + type: dataElementTypes.TEXT, + placement: Placements.BOTTOM, + label: i18n.t('Status'), + convertFn: (value: string) => statusLabels[value], + }, + }; -export const getDataEntryDetails = (linkedEvent: LinkedEvent, formFoundation: RenderFoundation) => { - const dataEntryValues = Object.values(DataEntryFieldsToInclude).map((entry: any) => { + const dataEntryValues = Object.values(dataEntryFieldsToInclude).map((entry: any) => { const value = linkedEvent[entry.apiKey]; if (!value) return null; diff --git a/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts b/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts index e70af09e5d..577af518e8 100644 --- a/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts +++ b/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts @@ -2,8 +2,6 @@ import i18n from '@dhis2/d2-i18n'; import type { DataElement } from '../metaData'; import { convertClientToView } from '../converters'; -const DEFAULT_NAME = i18n.t('tracked entity instance'); - export function getDisplayName( values: { [attrId: string]: any }, attributes: Array, @@ -13,7 +11,7 @@ export function getDisplayName( const displayValues = attributes.filter(a => valueIds.some(id => id === a.id) && a.displayInReports); if (displayValues.length === 0) { - return fallbackName || DEFAULT_NAME; + return fallbackName || i18n.t('tracked entity instance'); } return displayValues.slice(0, 2) From 718ae465453b49f8f2b4824e5f7ac9e10009bd64 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:26:43 +0000 Subject: [PATCH 18/24] feat: enhance custom terminology handling and optimize location query caching --- i18n/en.pot | 4 ++-- .../customLabels/applyCustomTerminology.ts | 12 ++++++---- .../bootstrapCustomTerminology.ts | 11 ++++++++- .../utils/routing/getLocationQuery.ts | 24 +++++++++++++++---- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d59d365cad..e3911f7bb7 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-12T12:20:40.672Z\n" -"PO-Revision-Date: 2026-08-12T12:20:40.672Z\n" +"POT-Creation-Date: 2026-08-12T12:26:45.741Z\n" +"PO-Revision-Date: 2026-08-12T12:26:45.741Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts index 70002ff2fe..1c2ce47edb 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts @@ -38,10 +38,14 @@ const TERM_ENTRIES: ReadonlyArray = ( const escapeRegExp = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); -const COMBINED_PATTERN = new RegExp( - String.raw`\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\b`, - 'gi', -); +const COMBINED_PATTERN_SOURCE = String.raw`\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\b`; +const COMBINED_PATTERN = new RegExp(COMBINED_PATTERN_SOURCE, 'gi'); +// Non-global variant used only for the wrapper fast-path — `.test` on a +// global regex is stateful (advances lastIndex), which we want to avoid. +const HAS_ANY_TOKEN_PATTERN = new RegExp(COMBINED_PATTERN_SOURCE, 'i'); + +export const hasCustomTerminologyTokens = (text: string): boolean => + typeof text === 'string' && HAS_ANY_TOKEN_PATTERN.test(text); const findEntry = (match: string): TermEntry | undefined => { const lower = match.toLowerCase(); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts index 8635a28e58..69750a61c3 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts @@ -1,5 +1,10 @@ import i18n from '@dhis2/d2-i18n'; -import { applyCustomTerminology, INTERPOLATION_OPEN, INTERPOLATION_CLOSE } from './applyCustomTerminology'; +import { + applyCustomTerminology, + hasCustomTerminologyTokens, + INTERPOLATION_OPEN, + INTERPOLATION_CLOSE, +} from './applyCustomTerminology'; import { resolveTerminologyContext } from './resolveTerminologyContext'; type ReduxStore = { getState: () => unknown }; @@ -44,6 +49,10 @@ export const bootstrapCustomTerminology = (store: ReduxStore) => { const originalT = i18n.t.bind(i18n); i18n.t = (key: string, options?: any) => { const translated = originalT(key, wrapInterpolationValues(options)); + // Skip context resolution + regex replace when no terminology token + // appears anywhere in the translated string — the common case for + // most UI strings (buttons, dates, generic labels). + if (!hasCustomTerminologyTokens(translated)) return translated; return applyCustomTerminology(translated, resolveTerminologyContext(store)); }; }; diff --git a/src/core_modules/capture-core/utils/routing/getLocationQuery.ts b/src/core_modules/capture-core/utils/routing/getLocationQuery.ts index b3870d1b2d..322d6ba38d 100644 --- a/src/core_modules/capture-core/utils/routing/getLocationQuery.ts +++ b/src/core_modules/capture-core/utils/routing/getLocationQuery.ts @@ -1,7 +1,21 @@ +// Cached across calls — window.location.hash doesn't change mid-render, but +// this is called from hot paths (customLabels wrapper, epics) that would +// otherwise re-parse the URL and allocate a new object on every invocation. +let cachedHash: string | undefined; +let cachedQuery: Readonly> | undefined; + export const getLocationQuery = (): any => { - const urlSearchParamString = window.location.hash.split('?')[1]; - return [...new URLSearchParams(urlSearchParamString).entries()].reduce((accParams, [key, value]) => { - accParams[key] = value; - return accParams; - }, {}); + const hash = window.location.hash; + if (cachedQuery && hash === cachedHash) return cachedQuery; + const urlSearchParamString = hash.split('?')[1]; + const query = [...new URLSearchParams(urlSearchParamString).entries()].reduce>( + (accParams, [key, value]) => { + accParams[key] = value; + return accParams; + }, + {}, + ); + cachedHash = hash; + cachedQuery = Object.freeze(query); + return cachedQuery; }; From b52ffd8dc6c6696b1e6d986b2fdda75b5aaff32f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:43:52 +0000 Subject: [PATCH 19/24] feat: custom terminology context handling --- i18n/en.pot | 4 +- .../WidgetProfile/hooks/useApiProgram.ts | 60 ++++++++----------- .../useGroupedLinkedEntities.ts | 14 ++++- .../bootstrapCustomTerminology.ts | 26 +++++--- .../metaData/helpers/customLabels/index.ts | 2 + .../customLabels/programTerminologyContext.ts | 37 ++++++++++++ .../customLabels/resolveTerminologyContext.ts | 30 +++++----- .../helpers/customLabels/useProgramT.ts | 24 ++++++++ .../storeTrackedEntityTypes.ts | 14 ++--- .../utils/routing/getLocationQuery.ts | 24 ++------ src/declarations.d.ts | 3 +- 11 files changed, 149 insertions(+), 89 deletions(-) create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts diff --git a/i18n/en.pot b/i18n/en.pot index e3911f7bb7..6c6b4d23cd 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-12T12:26:45.741Z\n" -"PO-Revision-Date: 2026-08-12T12:26:45.741Z\n" +"POT-Creation-Date: 2026-08-12T13:43:54.110Z\n" +"PO-Revision-Date: 2026-08-12T13:43:54.110Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts index 95a875dbe6..97fae559f4 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts @@ -1,45 +1,37 @@ import { useMemo } from 'react'; import { useDataQuery } from '@dhis2/app-runtime'; -import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; -const baseTrackedEntityTypeFields = +const trackedEntityTypeFields = 'id,access,displayName,allowAuditLog,minAttributesRequiredToSearch,featureType,' + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + 'translations[property,locale,value]'; -const pluralTrackedEntityTypeFields = 'displayTrackedEntityTypesLabel'; - -const buildFields = (includePluralLabels: boolean) => { - const trackedEntityTypeFields = includePluralLabels - ? `${baseTrackedEntityTypeFields},${pluralTrackedEntityTypeFields}` - : baseTrackedEntityTypeFields; - return 'id,version,displayName,displayShortName,description,programType,style,minAttributesRequiredToSearch,' + - 'enrollmentDateLabel,incidentDateLabel,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture,' + - 'displayIncidentDate,access[*],' + +const buildFields = (): string => + 'id,version,displayName,displayShortName,description,programType,style,minAttributesRequiredToSearch,' + + 'enrollmentDateLabel,incidentDateLabel,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture,' + + 'displayIncidentDate,access[*],' + + 'dataEntryForm[id,htmlCode],' + + 'categoryCombo[id,displayName,isDefault,categories[id,displayName]],' + + 'programSections[id,displayFormName,displayDescription,sortOrder,trackedEntityAttributes],' + + 'programRuleVariables[id,displayName,programRuleVariableSourceType,valueType,program[id],' + + 'programStage[id],dataElement[id],trackedEntityAttribute[id],useCodeForOptionSet],' + + 'programStages[id,access,autoGenerateEvent,openAfterEnrollment,generatedByEnrollmentDate,' + + 'reportDateToUse,minDaysFromStart,displayName,description,executionDateLabel,formType,featureType,' + + 'validationStrategy,enableUserAssignment,style,' + 'dataEntryForm[id,htmlCode],' + - 'categoryCombo[id,displayName,isDefault,categories[id,displayName]],' + - 'programSections[id,displayFormName,displayDescription,sortOrder,trackedEntityAttributes],' + - 'programRuleVariables[id,displayName,programRuleVariableSourceType,valueType,program[id],' + - 'programStage[id],dataElement[id],trackedEntityAttribute[id],useCodeForOptionSet],' + - 'programStages[id,access,autoGenerateEvent,openAfterEnrollment,generatedByEnrollmentDate,' + - 'reportDateToUse,minDaysFromStart,displayName,description,executionDateLabel,formType,featureType,' + - 'validationStrategy,enableUserAssignment,style,' + - 'dataEntryForm[id,htmlCode],' + - 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]],' + - 'programStageDataElements[compulsory,displayInReports,renderOptionsAsRadio,allowFutureDate,' + - 'renderType[*],dataElement[id,displayName,displayShortName,displayFormName,valueType,' + - 'translations[*],description,optionSetValue,style,optionSet[id,displayName,version,valueType,' + - 'options[id,displayName,code,style, translations]]]]],' + - 'programTrackedEntityAttributes[trackedEntityAttribute[id,displayName,displayShortName,displayFormName,' + - 'displayDescription,valueType,optionSetValue,unique,orgunitScope,pattern,translations[property,locale,value],' + - 'optionSet[id,displayName,version,valueType,options[id,displayName,name,code,style,translations]]],' + - 'displayInList,searchable,mandatory,renderOptionsAsRadio,allowFutureDate],' + - `trackedEntityType[${trackedEntityTypeFields}],` + - 'userRoles[id,displayName]'; -}; + 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]],' + + 'programStageDataElements[compulsory,displayInReports,renderOptionsAsRadio,allowFutureDate,' + + 'renderType[*],dataElement[id,displayName,displayShortName,displayFormName,valueType,' + + 'translations[*],description,optionSetValue,style,optionSet[id,displayName,version,valueType,' + + 'options[id,displayName,code,style, translations]]]]],' + + 'programTrackedEntityAttributes[trackedEntityAttribute[id,displayName,displayShortName,displayFormName,' + + 'displayDescription,valueType,optionSetValue,unique,orgunitScope,pattern,translations[property,locale,value],' + + 'optionSet[id,displayName,version,valueType,options[id,displayName,name,code,style,translations]]],' + + 'displayInList,searchable,mandatory,renderOptionsAsRadio,allowFutureDate],' + + `trackedEntityType[${trackedEntityTypeFields}],` + + 'userRoles[id,displayName]'; export const useApiProgram = (programId: string) => { - const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const { error, loading, data } = useDataQuery( useMemo( () => ({ @@ -47,11 +39,11 @@ export const useApiProgram = (programId: string) => { resource: 'programs', id: programId, params: { - fields: buildFields(includePluralLabels), + fields: buildFields(), }, }, }), - [programId, includePluralLabels], + [programId], ), ); diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts index bcd69e09cb..8a54b9b721 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts @@ -4,11 +4,17 @@ import moment from 'moment'; import i18n from '@dhis2/d2-i18n'; import { errorCreator } from 'capture-core-utils'; import { dataElementTypes } from '../../../../metaData'; +import { withProgramTerminologyContext } from '../../../../metaData/helpers/customLabels'; import { RELATIONSHIP_ENTITIES } from '../constants'; import { convertClientToList, convertServerToClient } from '../../../../converters'; import type { GroupedLinkedEntities, LinkedEntityData } from './types'; import type { ApiLinkedEntity, InputRelationshipData, RelationshipTypes } from '../Types'; +const getConstraintTerminologyContext = (constraint: any) => ({ + programId: constraint.program?.id, + stageId: 'programStage' in constraint ? constraint.programStage.id : undefined, +}); + const getFallbackFieldsByRelationshipEntity = { [RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE]: () => [{ id: 'trackedEntityTypeName', @@ -224,7 +230,13 @@ export const useGroupedLinkedEntities = ( { constraint: relationshipType.fromConstraint, name: relationshipType.toFromName } : { constraint: relationshipType.toConstraint, name: relationshipType.fromToName }; - const columns = getColumns(constraint); + // Use the constraint's own program for terminology so that + // column headers (e.g. "Program stage name") reflect the + // linked program's labels, not the currently-selected program. + const columns = withProgramTerminologyContext( + getConstraintTerminologyContext(constraint), + () => getColumns(constraint), + ); const context = getContext(constraint, relationshipType.access, readOnly); accGroupedLinkedEntities.push({ diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts index 69750a61c3..6d20034855 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts @@ -46,13 +46,23 @@ export const bootstrapCustomTerminology = (store: ReduxStore) => { if (bootstrapped) return; bootstrapped = true; + // Register terminology replacement as an i18next postProcessor plugin so it + // uses the framework's documented extension point rather than overwriting t. + i18n.use({ + type: 'postProcessor' as const, + name: 'customTerminology', + process(value: string): string { + if (!hasCustomTerminologyTokens(value)) return value; + return applyCustomTerminology(value, resolveTerminologyContext(store)); + }, + }); + // Enable the plugin globally — i18next reads this from options at call time. + (i18n.options as any).postProcess = 'customTerminology'; + + // Thin intercept solely to bracket interpolated values with sentinel markers + // before i18next performs interpolation. This prevents terminology replacement + // from rewriting server-supplied names (e.g. a stage called "Birth event"). + // No equivalent pre-interpolation hook exists in the i18next plugin API. const originalT = i18n.t.bind(i18n); - i18n.t = (key: string, options?: any) => { - const translated = originalT(key, wrapInterpolationValues(options)); - // Skip context resolution + regex replace when no terminology token - // appears anywhere in the translated string — the common case for - // most UI strings (buttons, dates, generic labels). - if (!hasCustomTerminologyTokens(translated)) return translated; - return applyCustomTerminology(translated, resolveTerminologyContext(store)); - }; + i18n.t = (key: string, options?: any) => originalT(key, wrapInterpolationValues(options)); }; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts index 06a2a4cd5d..499df058c3 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -7,3 +7,5 @@ export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels' export { applyCustomTerminology } from './applyCustomTerminology'; export type { TerminologyContext } from './applyCustomTerminology'; export { bootstrapCustomTerminology } from './bootstrapCustomTerminology'; +export { withProgramTerminologyContext } from './programTerminologyContext'; +export { useProgramT } from './useProgramT'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts new file mode 100644 index 0000000000..3e6a90aa18 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts @@ -0,0 +1,37 @@ +import type { TerminologyContext } from './applyCustomTerminology'; + +// Module-level stack of explicit program contexts. A stack (rather than a single +// value) correctly handles nested providers — e.g. a cross-program relationships +// widget inside a program-scoped page shell. +// +// Correctness note: this relies on React rendering being synchronous within a +// single tree. It is safe for non-concurrent render paths. If the app adopts +// React 18 concurrent features (startTransition, useDeferredValue) on paths +// that render cross-program widgets, revisit this mechanism. +const contextStack: Array = []; + +/** + * Runs `fn` with `context` as the active program terminology context. + * Any `i18n.t` calls made synchronously inside `fn` will use this context + * instead of the global Redux-derived context. + * + * Use this in non-React code (hooks, data builders, column factories) where + * you know the program that the translated strings are about — for example + * when building column definitions for a cross-program relationship widget. + */ +export const withProgramTerminologyContext = ( + context: TerminologyContext, + fn: () => T, +): T => { + contextStack.push(context); + try { + return fn(); + } finally { + contextStack.pop(); + } +}; + +/** Returns the innermost explicit context, or undefined if none is active. */ +export const getActiveProgramTerminologyContext = (): TerminologyContext | undefined => ( + contextStack.length > 0 ? contextStack[contextStack.length - 1] : undefined +); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts index 07c2156337..6b7e6b3fb7 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts @@ -1,32 +1,32 @@ import { getLocationQuery } from '../../../utils/routing'; +import { getActiveProgramTerminologyContext } from './programTerminologyContext'; import type { TerminologyContext } from './applyCustomTerminology'; type ReduxStore = { getState: () => unknown }; type DomainState = { + currentSelections?: { programId?: string }, enrollmentDomain?: { enrollment?: { program?: string }, }, }; export const resolveTerminologyContext = (store: ReduxStore): TerminologyContext => { - const query = getLocationQuery(); - - if (query.programId) { - return { - programId: query.programId, - stageId: query.stageId ?? query.programStageId, - }; - } + // Explicit context wins — set by withProgramTerminologyContext for cross-program widgets. + const explicit = getActiveProgramTerminologyContext(); + if (explicit !== undefined) return explicit; const state = (store.getState() ?? {}) as DomainState; + const programId = + state.currentSelections?.programId || + state.enrollmentDomain?.enrollment?.program; - if (query.enrollmentId || query.teiId) { - const enrollment = state.enrollmentDomain?.enrollment; - if (enrollment?.program) { - return { programId: enrollment.program }; - } - } + if (!programId) return {}; + + // stageId is not stored in Redux; read from the URL so that displayEventLabel + // overrides on tracker program stages apply on view/edit-event routes. + const query = getLocationQuery(); + const stageId = query.stageId ?? query.programStageId; - return {}; + return stageId ? { programId, stageId } : { programId }; }; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts new file mode 100644 index 0000000000..fcf0713664 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { withProgramTerminologyContext } from './programTerminologyContext'; + +/** + * Returns a `t` function that applies terminology for the given program/stage + * rather than the globally-selected program. + * + * Use this in React components that render data belonging to a program that + * differs from the one currently selected in the URL/Redux state — e.g. a + * relationships widget displaying events from a linked program. + * + * const t = useProgramT(relationship.program.id); + * return {t('New event')}; // uses the linked program's label + */ +export const useProgramT = ( + programId: string | undefined, + stageId?: string | undefined, +): ((key: string, options?: Record) => string) => + useCallback( + (key: string, options?: Record) => + withProgramTerminologyContext({ programId, stageId }, () => i18n.t(key, options as any)), + [programId, stageId], + ); diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts index bb12dba43c..4271797227 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; import { quickStore } from '../../IOUtils'; import { getContext } from '../../context'; @@ -27,19 +26,16 @@ const convert = (() => { })); })(); -const buildFieldsParam = (includePluralLabels: boolean): string => { - const labels = includePluralLabels ? 'displayName,displayTrackedEntityTypesLabel' : 'displayName'; - return `id,access,${labels},minAttributesRequiredToSearch,featureType,` + - 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + - 'translations[property,locale,value]'; -}; +const FIELDS = + 'id,access,displayName,minAttributesRequiredToSearch,featureType,' + + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + + 'translations[property,locale,value]'; export const storeTrackedEntityTypes = (ids: Array) => { - const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const query = { resource: 'trackedEntityTypes', params: { - fields: buildFieldsParam(includePluralLabels), + fields: FIELDS, filter: `id:in:[${ids.join(',')}]`, pageSize: ids.length, }, diff --git a/src/core_modules/capture-core/utils/routing/getLocationQuery.ts b/src/core_modules/capture-core/utils/routing/getLocationQuery.ts index 322d6ba38d..b3870d1b2d 100644 --- a/src/core_modules/capture-core/utils/routing/getLocationQuery.ts +++ b/src/core_modules/capture-core/utils/routing/getLocationQuery.ts @@ -1,21 +1,7 @@ -// Cached across calls — window.location.hash doesn't change mid-render, but -// this is called from hot paths (customLabels wrapper, epics) that would -// otherwise re-parse the URL and allocate a new object on every invocation. -let cachedHash: string | undefined; -let cachedQuery: Readonly> | undefined; - export const getLocationQuery = (): any => { - const hash = window.location.hash; - if (cachedQuery && hash === cachedHash) return cachedQuery; - const urlSearchParamString = hash.split('?')[1]; - const query = [...new URLSearchParams(urlSearchParamString).entries()].reduce>( - (accParams, [key, value]) => { - accParams[key] = value; - return accParams; - }, - {}, - ); - cachedHash = hash; - cachedQuery = Object.freeze(query); - return cachedQuery; + const urlSearchParamString = window.location.hash.split('?')[1]; + return [...new URLSearchParams(urlSearchParamString).entries()].reduce((accParams, [key, value]) => { + accParams[key] = value; + return accParams; + }, {}); }; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 561b1762ee..6f3131edc2 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -14,7 +14,8 @@ declare module '@dhis2/d2-i18n' { const i18n: { t: (key: string, options?: any) => any; language: string; - // Add other methods as needed + use: (module: any) => typeof i18n; + options: Record; }; export default i18n; } From f3b34dc21f617af96c5da8ba15b48732d51881d9 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:52:58 +0000 Subject: [PATCH 20/24] fix: change approach --- i18n/en.pot | 4 +- .../useGroupedLinkedEntities.ts | 13 +-- .../customLabels/applyCustomTerminology.ts | 101 ------------------ .../bootstrapCustomTerminology.ts | 68 ------------ .../helpers/customLabels/customLabels.ts | 84 +++++++-------- .../metaData/helpers/customLabels/index.ts | 10 +- .../customLabels/programTerminologyContext.ts | 37 ------- .../customLabels/resolveTerminologyContext.ts | 32 ------ .../metaData/helpers/customLabels/useLabel.ts | 30 ++++++ .../helpers/customLabels/useProgramT.ts | 24 ----- .../capture-core/metaData/helpers/index.ts | 8 +- .../capture-core/metaData/index.ts | 8 +- src/store/getStore.ts | 3 - 13 files changed, 83 insertions(+), 339 deletions(-) delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts diff --git a/i18n/en.pot b/i18n/en.pot index 6c6b4d23cd..3a9cf58fe8 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-12T13:43:54.110Z\n" -"PO-Revision-Date: 2026-08-12T13:43:54.110Z\n" +"POT-Creation-Date: 2026-08-12T13:53:00.924Z\n" +"PO-Revision-Date: 2026-08-12T13:53:00.924Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts index 8a54b9b721..0da4a832ef 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts @@ -4,16 +4,11 @@ import moment from 'moment'; import i18n from '@dhis2/d2-i18n'; import { errorCreator } from 'capture-core-utils'; import { dataElementTypes } from '../../../../metaData'; -import { withProgramTerminologyContext } from '../../../../metaData/helpers/customLabels'; import { RELATIONSHIP_ENTITIES } from '../constants'; import { convertClientToList, convertServerToClient } from '../../../../converters'; import type { GroupedLinkedEntities, LinkedEntityData } from './types'; import type { ApiLinkedEntity, InputRelationshipData, RelationshipTypes } from '../Types'; -const getConstraintTerminologyContext = (constraint: any) => ({ - programId: constraint.program?.id, - stageId: 'programStage' in constraint ? constraint.programStage.id : undefined, -}); const getFallbackFieldsByRelationshipEntity = { [RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE]: () => [{ @@ -230,13 +225,7 @@ export const useGroupedLinkedEntities = ( { constraint: relationshipType.fromConstraint, name: relationshipType.toFromName } : { constraint: relationshipType.toConstraint, name: relationshipType.fromToName }; - // Use the constraint's own program for terminology so that - // column headers (e.g. "Program stage name") reflect the - // linked program's labels, not the currently-selected program. - const columns = withProgramTerminologyContext( - getConstraintTerminologyContext(constraint), - () => getColumns(constraint), - ); + const columns = getColumns(constraint); const context = getContext(constraint, relationshipType.access, readOnly); accGroupedLinkedEntities.push({ diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts deleted file mode 100644 index 1c2ce47edb..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/applyCustomTerminology.ts +++ /dev/null @@ -1,101 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { programCollection } from '../../../metaDataMemoryStores'; -import { CUSTOM_LABEL_FIELDS, resolveCustomLabel } from './customLabels'; -import type { CustomLabelKey, CustomLabels, CustomLabelField } from './customLabels'; - -export type TerminologyContext = { - programId?: string, - stageId?: string, -}; - -// Unicode "non-characters" (U+FDD0 / U+FDD1) — the Unicode standard reserves -// these to never be used for text, so they never collide with real content. -// The bootstrap wrapper brackets every interpolated value with these markers -// so we can skip them here and avoid rewriting server-supplied names that -// happen to contain a token word (e.g. a stage named "Birth event"). -export const INTERPOLATION_OPEN = '﷐'; -export const INTERPOLATION_CLOSE = '﷑'; -const INTERPOLATION_PATTERN = new RegExp(`${INTERPOLATION_OPEN}(.*?)${INTERPOLATION_CLOSE}`, 'g'); - -type TermEntry = { - key: CustomLabelKey, - plural: boolean, - english: string, -}; - -const TERM_ENTRIES: ReadonlyArray = ( - Object.entries(CUSTOM_LABEL_FIELDS) as ReadonlyArray<[CustomLabelKey, CustomLabelField]> -).flatMap(([key, forms]) => { - const out: TermEntry[] = []; - const addForm = (form: { english: string, aliases?: ReadonlyArray }, plural: boolean) => { - out.push({ key, plural, english: form.english }); - (form.aliases ?? []).forEach(alias => out.push({ key, plural, english: alias })); - }; - if (forms.plural) addForm(forms.plural, true); - if (forms.singular) addForm(forms.singular, false); - return out; -}).sort((a, b) => b.english.length - a.english.length); - -const escapeRegExp = (input: string): string => input.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); - -const COMBINED_PATTERN_SOURCE = String.raw`\b(${TERM_ENTRIES.map(entry => escapeRegExp(entry.english)).join('|')})\b`; -const COMBINED_PATTERN = new RegExp(COMBINED_PATTERN_SOURCE, 'gi'); -// Non-global variant used only for the wrapper fast-path — `.test` on a -// global regex is stateful (advances lastIndex), which we want to avoid. -const HAS_ANY_TOKEN_PATTERN = new RegExp(COMBINED_PATTERN_SOURCE, 'i'); - -export const hasCustomTerminologyTokens = (text: string): boolean => - typeof text === 'string' && HAS_ANY_TOKEN_PATTERN.test(text); - -const findEntry = (match: string): TermEntry | undefined => { - const lower = match.toLowerCase(); - return TERM_ENTRIES.find(entry => entry.english === lower); -}; - -const preserveCase = (match: string, replacement: string, locale: string): string => { - if (match.length > 1 && match === match.toLocaleUpperCase(locale)) { - return replacement.toLocaleUpperCase(locale); - } - const firstUpper = match.charAt(0).toLocaleUpperCase(locale); - if (match.startsWith(firstUpper)) { - return replacement.charAt(0).toLocaleUpperCase(locale) + replacement.slice(1); - } - return replacement; -}; - -const getLabelSources = ({ - programId, - stageId, -}: TerminologyContext): Array => { - const program = programId ? programCollection.get(programId) : undefined; - const stage = program && stageId ? program.getStage(stageId) : undefined; - return [stage?.customLabels, program?.customLabels]; -}; - -export const applyCustomTerminology = ( - translatedText: string, - context: TerminologyContext = {}, -): string => { - if (typeof translatedText !== 'string' || !translatedText) return translatedText; - const { programId, stageId } = context; - if (!programId && !stageId) return translatedText; - - const sources = getLabelSources(context); - const locale = i18n.language || 'en'; - - const substitute = (text: string): string => text.replace(COMBINED_PATTERN, (match) => { - const entry = findEntry(match); - if (!entry) return match; - const custom = resolveCustomLabel(sources, entry.key, { plural: entry.plural }); - if (!custom) return match; - return preserveCase(match, custom, locale); - }); - - // Split on sentinel-wrapped interpolation regions. String.split with a - // capturing group returns [outside, inside, outside, ...] — substitute - // only in the outside parts so server-supplied values pass through - // unchanged. If no sentinels are present, we get [translatedText] and - // just substitute the whole thing. - const parts = translatedText.split(INTERPOLATION_PATTERN); - return parts.map((part, i) => (i % 2 === 0 ? substitute(part) : part)).join(''); -}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts deleted file mode 100644 index 6d20034855..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/bootstrapCustomTerminology.ts +++ /dev/null @@ -1,68 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { - applyCustomTerminology, - hasCustomTerminologyTokens, - INTERPOLATION_OPEN, - INTERPOLATION_CLOSE, -} from './applyCustomTerminology'; -import { resolveTerminologyContext } from './resolveTerminologyContext'; - -type ReduxStore = { getState: () => unknown }; - -const I18N_CONTROL_KEYS = new Set([ - 'context', - 'count', - 'defaultValue', - 'fallbackLng', - 'interpolation', - 'joinArrays', - 'keySeparator', - 'lng', - 'lngs', - 'ns', - 'nsSeparator', - 'postProcess', - 'replace', - 'returnDetails', - 'returnObjects', - 'skipInterpolation', -]); - -const wrapInterpolationValues = (options?: Record): Record | undefined => { - if (!options || typeof options !== 'object') return options; - const wrapped: Record = {}; - for (const key of Object.keys(options)) { - const value = options[key]; - wrapped[key] = typeof value === 'string' && !I18N_CONTROL_KEYS.has(key) - ? `${INTERPOLATION_OPEN}${value}${INTERPOLATION_CLOSE}` - : value; - } - return wrapped; -}; - -let bootstrapped = false; - -export const bootstrapCustomTerminology = (store: ReduxStore) => { - if (bootstrapped) return; - bootstrapped = true; - - // Register terminology replacement as an i18next postProcessor plugin so it - // uses the framework's documented extension point rather than overwriting t. - i18n.use({ - type: 'postProcessor' as const, - name: 'customTerminology', - process(value: string): string { - if (!hasCustomTerminologyTokens(value)) return value; - return applyCustomTerminology(value, resolveTerminologyContext(store)); - }, - }); - // Enable the plugin globally — i18next reads this from options at call time. - (i18n.options as any).postProcess = 'customTerminology'; - - // Thin intercept solely to bracket interpolated values with sentinel markers - // before i18next performs interpolation. This prevents terminology replacement - // from rewriting server-supplied names (e.g. a stage called "Birth event"). - // No equivalent pre-interpolation hook exists in the i18next plugin API. - const originalT = i18n.t.bind(i18n); - i18n.t = (key: string, options?: any) => originalT(key, wrapInterpolationValues(options)); -}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 530add6dea..dd6c7c5d4c 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -1,63 +1,36 @@ -export type CustomLabelForm = { - field: string, - english: string, - aliases?: ReadonlyArray, -}; - -export type CustomLabelField = { - singular: CustomLabelForm, - plural?: CustomLabelForm, +type CustomLabelField = { + field?: string, + pluralField?: string, }; export const CUSTOM_LABEL_FIELDS = { - enrollment: { - singular: { field: 'displayEnrollmentLabel', english: 'enrollment' }, - plural: { field: 'displayEnrollmentsLabel', english: 'enrollments' }, - }, - event: { - singular: { field: 'displayEventLabel', english: 'event' }, - plural: { field: 'displayEventsLabel', english: 'events' }, - }, - note: { - singular: { field: 'displayNoteLabel', english: 'note' }, - }, - relationship: { - singular: { field: 'displayRelationshipLabel', english: 'relationship' }, - }, - attribute: { - singular: { field: 'displayTrackedEntityAttributeLabel', english: 'attribute' }, - }, - programStage: { - singular: { field: 'displayProgramStageLabel', english: 'program stage' }, - plural: { field: 'displayProgramStagesLabel', english: 'program stages' }, - }, - orgUnit: { - singular: { field: 'displayOrgUnitLabel', english: 'organisation unit' }, - plural: { field: 'displayOrgUnitLabel', english: 'organisation units' }, - }, - followUp: { - singular: { field: 'displayFollowUpLabel', english: 'follow-up' }, - }, + enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, + followUp: { field: 'displayFollowUpLabel' }, + orgUnit: { field: 'displayOrgUnitLabel' }, + relationship: { field: 'displayRelationshipLabel' }, + note: { field: 'displayNoteLabel' }, + attribute: { field: 'displayTrackedEntityAttributeLabel' }, + programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, + event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, } as const satisfies { [key: string]: CustomLabelField }; export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; export type CustomLabels = Record; export type LabelOptions = { plural?: boolean }; -const ALL_FIELDS: ReadonlyArray = Array.from( +const allFields: Array = Array.from( new Set( Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.singular.field, term.plural?.field]) + .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) .filter((field): field is string => Boolean(field)), ), ); -export const extractCustomLabels = (cached: Record): CustomLabels => { +export const extractCustomLabels = (cached: Record): CustomLabels => { const labels: CustomLabels = {}; - ALL_FIELDS.forEach((field) => { - const value = cached[field]; - if (typeof value === 'string' && value) { - labels[field] = value; + allFields.forEach((field) => { + if (cached[field]) { + labels[field] = cached[field]; } }); return labels; @@ -65,14 +38,29 @@ export const extractCustomLabels = (cached: Record): CustomLabe type LabelSource = CustomLabels | undefined | null; -export const resolveCustomLabel = ( +export const resolveLabel = ( sources: LabelSource | Array, key: CustomLabelKey, { plural = false }: LabelOptions = {}, ): string | undefined => { const term: CustomLabelField = CUSTOM_LABEL_FIELDS[key]; const list = Array.isArray(sources) ? sources : [sources]; - const form = plural && term.plural ? term.plural : term.singular; - if (!form) return undefined; - return list.find(source => source?.[form.field])?.[form.field]; + const pick = (field?: string) => (field ? list.find(source => source?.[field])?.[field] : undefined); + + if (plural) { + return term.pluralField ? pick(term.pluralField) : pick(term.field); + } + return pick(term.field); }; + +type WithLabels = { customLabels?: CustomLabels } | undefined | null; + +export const getProgramLabel = (program: WithLabels, key: CustomLabelKey, options?: LabelOptions): string | undefined => + resolveLabel(program?.customLabels, key, options); + +export const getStageLabel = ( + stage: WithLabels, + program: WithLabels, + key: CustomLabelKey, + options?: LabelOptions, +): string | undefined => resolveLabel([stage?.customLabels, program?.customLabels], key, options); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts index 499df058c3..0196f272ae 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -1,11 +1,9 @@ export { CUSTOM_LABEL_FIELDS, - resolveCustomLabel, + resolveLabel, extractCustomLabels, + getProgramLabel, + getStageLabel, } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { applyCustomTerminology } from './applyCustomTerminology'; -export type { TerminologyContext } from './applyCustomTerminology'; -export { bootstrapCustomTerminology } from './bootstrapCustomTerminology'; -export { withProgramTerminologyContext } from './programTerminologyContext'; -export { useProgramT } from './useProgramT'; +export { useProgramLabel, useStageLabel } from './useLabel'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts deleted file mode 100644 index 3e6a90aa18..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/programTerminologyContext.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { TerminologyContext } from './applyCustomTerminology'; - -// Module-level stack of explicit program contexts. A stack (rather than a single -// value) correctly handles nested providers — e.g. a cross-program relationships -// widget inside a program-scoped page shell. -// -// Correctness note: this relies on React rendering being synchronous within a -// single tree. It is safe for non-concurrent render paths. If the app adopts -// React 18 concurrent features (startTransition, useDeferredValue) on paths -// that render cross-program widgets, revisit this mechanism. -const contextStack: Array = []; - -/** - * Runs `fn` with `context` as the active program terminology context. - * Any `i18n.t` calls made synchronously inside `fn` will use this context - * instead of the global Redux-derived context. - * - * Use this in non-React code (hooks, data builders, column factories) where - * you know the program that the translated strings are about — for example - * when building column definitions for a cross-program relationship widget. - */ -export const withProgramTerminologyContext = ( - context: TerminologyContext, - fn: () => T, -): T => { - contextStack.push(context); - try { - return fn(); - } finally { - contextStack.pop(); - } -}; - -/** Returns the innermost explicit context, or undefined if none is active. */ -export const getActiveProgramTerminologyContext = (): TerminologyContext | undefined => ( - contextStack.length > 0 ? contextStack[contextStack.length - 1] : undefined -); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts deleted file mode 100644 index 6b7e6b3fb7..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/resolveTerminologyContext.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getLocationQuery } from '../../../utils/routing'; -import { getActiveProgramTerminologyContext } from './programTerminologyContext'; -import type { TerminologyContext } from './applyCustomTerminology'; - -type ReduxStore = { getState: () => unknown }; - -type DomainState = { - currentSelections?: { programId?: string }, - enrollmentDomain?: { - enrollment?: { program?: string }, - }, -}; - -export const resolveTerminologyContext = (store: ReduxStore): TerminologyContext => { - // Explicit context wins — set by withProgramTerminologyContext for cross-program widgets. - const explicit = getActiveProgramTerminologyContext(); - if (explicit !== undefined) return explicit; - - const state = (store.getState() ?? {}) as DomainState; - const programId = - state.currentSelections?.programId || - state.enrollmentDomain?.enrollment?.program; - - if (!programId) return {}; - - // stageId is not stored in Redux; read from the URL so that displayEventLabel - // overrides on tracker program stages apply on view/edit-event routes. - const query = getLocationQuery(); - const stageId = query.stageId ?? query.programStageId; - - return stageId ? { programId, stageId } : { programId }; -}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts new file mode 100644 index 0000000000..175b6e2dab --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { programCollection } from '../../../metaDataMemoryStores'; +import { resolveLabel } from './customLabels'; +import type { CustomLabelKey, LabelOptions } from './customLabels'; + +type ProgramOptions = LabelOptions & { programId?: string }; +type StageOptions = LabelOptions & { programId?: string, stageId?: string }; + +export const useProgramLabel = (key: CustomLabelKey, { programId, plural }: ProgramOptions = {}): string | undefined => { + const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); + const id = programId ?? currentProgramId; + return useMemo( + () => resolveLabel(id ? programCollection.get(id)?.customLabels : undefined, key, { plural }), + [id, key, plural], + ); +}; + +export const useStageLabel = ( + key: CustomLabelKey, + { programId, stageId, plural }: StageOptions = {}, +): string | undefined => { + const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); + const pId = programId ?? currentProgramId; + return useMemo(() => { + const program = pId ? programCollection.get(pId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + return resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); + }, [pId, stageId, key, plural]); +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts deleted file mode 100644 index fcf0713664..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useProgramT.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback } from 'react'; -import i18n from '@dhis2/d2-i18n'; -import { withProgramTerminologyContext } from './programTerminologyContext'; - -/** - * Returns a `t` function that applies terminology for the given program/stage - * rather than the globally-selected program. - * - * Use this in React components that render data belonging to a program that - * differs from the one currently selected in the URL/Redux state — e.g. a - * relationships widget displaying events from a linked program. - * - * const t = useProgramT(relationship.program.id); - * return {t('New event')}; // uses the linked program's label - */ -export const useProgramT = ( - programId: string | undefined, - stageId?: string | undefined, -): ((key: string, options?: Record) => string) => - useCallback( - (key: string, options?: Record) => - withProgramTerminologyContext({ programId, stageId }, () => i18n.t(key, options as any)), - [programId, stageId], - ); diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index e5a22c7396..e4f2d5393b 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -19,9 +19,11 @@ export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { CUSTOM_LABEL_FIELDS, - resolveCustomLabel, + resolveLabel, extractCustomLabels, - applyCustomTerminology, - bootstrapCustomTerminology, + getProgramLabel, + getStageLabel, + useProgramLabel, + useStageLabel, } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 06898f4539..2b00a795b8 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,9 +41,11 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, CUSTOM_LABEL_FIELDS, - resolveCustomLabel, + resolveLabel, extractCustomLabels, - applyCustomTerminology, - bootstrapCustomTerminology, + getProgramLabel, + getStageLabel, + useProgramLabel, + useStageLabel, } from './helpers'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; diff --git a/src/store/getStore.ts b/src/store/getStore.ts index ac3596982a..699b66dbe6 100644 --- a/src/store/getStore.ts +++ b/src/store/getStore.ts @@ -9,7 +9,6 @@ import { environments } from 'capture-core/constants/environments'; import { createOffline } from '@redux-offline/redux-offline'; import offlineConfig from '@redux-offline/redux-offline/lib/defaults'; import { getEffectReconciler, shouldDiscard, queueConfig } from 'capture-core/trackerOffline'; -import { bootstrapCustomTerminology } from 'capture-core/metaData/helpers/customLabels'; import { getPersistOptions } from './persist/persistOptionsGetter'; import { reducerDescriptions } from '../reducers/descriptions/trackerCapture.reducerDescriptions'; import { epics } from '../epics/trackerCapture.epics'; @@ -56,7 +55,5 @@ export async function getStore( epicMiddleware.run(epics); - bootstrapCustomTerminology(store); - return store; } From 2b92b9aaa2a91337b3b3cef6c03ea8b964fccd2d Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:01:34 +0000 Subject: [PATCH 21/24] fix: complete clean up after revert --- i18n/en.pot | 4 ++-- .../common/RelationshipsWidget/useGroupedLinkedEntities.ts | 1 - src/declarations.d.ts | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3a9cf58fe8..de14654603 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-12T13:53:00.924Z\n" -"PO-Revision-Date: 2026-08-12T13:53:00.924Z\n" +"POT-Creation-Date: 2026-08-12T14:01:36.078Z\n" +"PO-Revision-Date: 2026-08-12T14:01:36.078Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts index 0da4a832ef..bcd69e09cb 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts @@ -9,7 +9,6 @@ import { convertClientToList, convertServerToClient } from '../../../../converte import type { GroupedLinkedEntities, LinkedEntityData } from './types'; import type { ApiLinkedEntity, InputRelationshipData, RelationshipTypes } from '../Types'; - const getFallbackFieldsByRelationshipEntity = { [RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE]: () => [{ id: 'trackedEntityTypeName', diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 6f3131edc2..1cc6a18172 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -13,9 +13,7 @@ declare module 'src/core_modules/*'; declare module '@dhis2/d2-i18n' { const i18n: { t: (key: string, options?: any) => any; - language: string; - use: (module: any) => typeof i18n; - options: Record; + // Add other methods as needed }; export default i18n; } From d66e4223d56270e35d1a539b7e8b556b1a454684 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:40:51 +0000 Subject: [PATCH 22/24] feat: update custom label handling and refactor label exports --- i18n/en.pot | 22 ++++++- .../helpers/customLabels/customLabels.ts | 36 +++-------- .../metaData/helpers/customLabels/index.ts | 10 +-- .../metaData/helpers/customLabels/useLabel.ts | 61 +++++++++++++------ .../capture-core/metaData/helpers/index.ts | 10 +-- .../capture-core/metaData/index.ts | 8 +-- 6 files changed, 78 insertions(+), 69 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index de14654603..a5f2413231 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-12T14:01:36.078Z\n" -"PO-Revision-Date: 2026-08-12T14:01:36.078Z\n" +"POT-Creation-Date: 2026-08-12T14:40:52.846Z\n" +"PO-Revision-Date: 2026-08-12T14:40:52.846Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -2242,6 +2242,24 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" +msgid "program stage" +msgstr "program stage" + +msgid "note" +msgstr "note" + +msgid "relationship" +msgstr "relationship" + +msgid "attribute" +msgstr "attribute" + +msgid "organisation unit" +msgstr "organisation unit" + +msgid "follow-up" +msgstr "follow-up" + msgid "Program not found" msgstr "Program not found" diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index dd6c7c5d4c..04bd973732 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -5,13 +5,13 @@ type CustomLabelField = { export const CUSTOM_LABEL_FIELDS = { enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, - followUp: { field: 'displayFollowUpLabel' }, - orgUnit: { field: 'displayOrgUnitLabel' }, - relationship: { field: 'displayRelationshipLabel' }, + event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, + programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, note: { field: 'displayNoteLabel' }, + relationship: { field: 'displayRelationshipLabel' }, attribute: { field: 'displayTrackedEntityAttributeLabel' }, - programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, - event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, + orgUnit: { field: 'displayOrgUnitLabel' }, + followUp: { field: 'displayFollowUpLabel' }, } as const satisfies { [key: string]: CustomLabelField }; export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; @@ -29,9 +29,7 @@ const allFields: Array = Array.from( export const extractCustomLabels = (cached: Record): CustomLabels => { const labels: CustomLabels = {}; allFields.forEach((field) => { - if (cached[field]) { - labels[field] = cached[field]; - } + if (cached[field]) labels[field] = cached[field]; }); return labels; }; @@ -43,24 +41,8 @@ export const resolveLabel = ( key: CustomLabelKey, { plural = false }: LabelOptions = {}, ): string | undefined => { - const term: CustomLabelField = CUSTOM_LABEL_FIELDS[key]; + const term = CUSTOM_LABEL_FIELDS[key] as CustomLabelField; const list = Array.isArray(sources) ? sources : [sources]; - const pick = (field?: string) => (field ? list.find(source => source?.[field])?.[field] : undefined); - - if (plural) { - return term.pluralField ? pick(term.pluralField) : pick(term.field); - } - return pick(term.field); + const pick = (field?: string) => (field ? list.find(s => s?.[field])?.[field] : undefined); + return plural && term.pluralField ? pick(term.pluralField) : pick(term.field); }; - -type WithLabels = { customLabels?: CustomLabels } | undefined | null; - -export const getProgramLabel = (program: WithLabels, key: CustomLabelKey, options?: LabelOptions): string | undefined => - resolveLabel(program?.customLabels, key, options); - -export const getStageLabel = ( - stage: WithLabels, - program: WithLabels, - key: CustomLabelKey, - options?: LabelOptions, -): string | undefined => resolveLabel([stage?.customLabels, program?.customLabels], key, options); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts index 0196f272ae..08d6759b09 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -1,9 +1,3 @@ -export { - CUSTOM_LABEL_FIELDS, - resolveLabel, - extractCustomLabels, - getProgramLabel, - getStageLabel, -} from './customLabels'; +export { CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { useProgramLabel, useStageLabel } from './useLabel'; +export { getTermLabel, useTermLabel } from './useLabel'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts index 175b6e2dab..9f0402fa65 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts @@ -1,30 +1,55 @@ +import i18n from '@dhis2/d2-i18n'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { programCollection } from '../../../metaDataMemoryStores'; import { resolveLabel } from './customLabels'; import type { CustomLabelKey, LabelOptions } from './customLabels'; -type ProgramOptions = LabelOptions & { programId?: string }; -type StageOptions = LabelOptions & { programId?: string, stageId?: string }; +const defaults: Record string> = { + enrollment: () => i18n.t('enrollment'), + event: () => i18n.t('event'), + programStage: () => i18n.t('program stage'), + note: () => i18n.t('note'), + relationship: () => i18n.t('relationship'), + attribute: () => i18n.t('attribute'), + orgUnit: () => i18n.t('organisation unit'), + followUp: () => i18n.t('follow-up'), +}; -export const useProgramLabel = (key: CustomLabelKey, { programId, plural }: ProgramOptions = {}): string | undefined => { - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const id = programId ?? currentProgramId; - return useMemo( - () => resolveLabel(id ? programCollection.get(id)?.customLabels : undefined, key, { plural }), - [id, key, plural], - ); +type TermLabelOptions = LabelOptions & { stageId?: string }; + +const resolve = ( + programId: string | undefined, + key: CustomLabelKey, + { stageId, plural }: TermLabelOptions, +): string => { + const program = programId ? programCollection.get(programId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + return resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }) + ?? defaults[key](); }; -export const useStageLabel = ( +/** + * Works anywhere — components, reducers, epics. + * Returns the custom label from the program (or stage), falling back to the + * translated default term. + */ +export const getTermLabel = ( + programId: string | undefined, + key: CustomLabelKey, + options: TermLabelOptions = {}, +): string => resolve(programId, key, options); + +/** + * React hook version — reads programId from Redux automatically. + * Pass programId explicitly to override (e.g. cross-program widgets). + */ +export const useTermLabel = ( key: CustomLabelKey, - { programId, stageId, plural }: StageOptions = {}, -): string | undefined => { + options: TermLabelOptions & { programId?: string } = {}, +): string => { + const { programId, stageId, plural } = options; const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const pId = programId ?? currentProgramId; - return useMemo(() => { - const program = pId ? programCollection.get(pId) : undefined; - const stage = program && stageId ? program.getStage(stageId) : undefined; - return resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - }, [pId, stageId, key, plural]); + const id = programId ?? currentProgramId; + return useMemo(() => resolve(id, key, { stageId, plural }), [id, key, stageId, plural]); }; diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index e4f2d5393b..c46d3adf0e 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -17,13 +17,5 @@ export { getScopeInfo } from './getScopeInfo'; export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; -export { - CUSTOM_LABEL_FIELDS, - resolveLabel, - extractCustomLabels, - getProgramLabel, - getStageLabel, - useProgramLabel, - useStageLabel, -} from './customLabels'; +export { CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel, getTermLabel, useTermLabel } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 2b00a795b8..139fb995a4 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,11 +41,9 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, CUSTOM_LABEL_FIELDS, - resolveLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - useProgramLabel, - useStageLabel, + resolveLabel, + getTermLabel, + useTermLabel, } from './helpers'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; From 762f6bd3e4f7ac9d0338c1bfbeaed853da7c6ee4 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:47:18 +0000 Subject: [PATCH 23/24] fix: correct capitalization in widget header and improve label resolution logic --- i18n/en.pot | 8 ++++---- .../WidgetStagesAndEvents.component.tsx | 2 +- .../metaData/helpers/customLabels/customLabels.ts | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index a5f2413231..c735b7dc70 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-12T14:40:52.846Z\n" -"PO-Revision-Date: 2026-08-12T14:40:52.846Z\n" +"POT-Creation-Date: 2026-08-12T14:47:19.687Z\n" +"PO-Revision-Date: 2026-08-12T14:47:19.687Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1801,8 +1801,8 @@ msgstr "{{ scheduledEvents }} scheduled" msgid "No program stages found in this program" msgstr "No program stages found in this program" -msgid "Program stages and Events" -msgstr "Program stages and Events" +msgid "Program stages and events" +msgstr "Program stages and events" msgid "An error occurred while unlinking and deleting the event." msgstr "An error occurred while unlinking and deleting the event." diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx index c17deb7e4c..13a96518fc 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -44,7 +44,7 @@ const WidgetStagesAndEventsPlain = ({ - {i18n.t('Program stages and Events')} + {i18n.t('Program stages and events')} {showWidgetBadge && (
(field ? list.find(s => s?.[field])?.[field] : undefined); - return plural && term.pluralField ? pick(term.pluralField) : pick(term.field); + if (plural && term.pluralField) { + return pick(term.pluralField) ?? pick(term.field); + } + return pick(term.field); }; From ee3d687ccbdb4401541d5474a958654c9dfed490 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:56:14 +0000 Subject: [PATCH 24/24] fix: simplify label resolution logic by removing fallback for plural fields --- i18n/en.pot | 4 ++-- .../metaData/helpers/customLabels/customLabels.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c735b7dc70..ea6ed29567 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-12T14:47:19.687Z\n" -"PO-Revision-Date: 2026-08-12T14:47:19.687Z\n" +"POT-Creation-Date: 2026-08-13T09:56:16.886Z\n" +"PO-Revision-Date: 2026-08-13T09:56:16.886Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts index 7396b753b5..bf60ed5c72 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -45,7 +45,7 @@ export const resolveLabel = ( const list = Array.isArray(sources) ? sources : [sources]; const pick = (field?: string) => (field ? list.find(s => s?.[field])?.[field] : undefined); if (plural && term.pluralField) { - return pick(term.pluralField) ?? pick(term.field); + return pick(term.pluralField); } return pick(term.field); };