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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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); }; From 8b7afbb63abf2839eb8c1e76ff6c216281c00972 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:18:20 +0000 Subject: [PATCH 25/57] feat: new translate function --- i18n/en.pot | 7 ++--- .../WidgetEnrollment.component.tsx | 15 +++++++--- .../ScheduleDate/ScheduleDate.component.tsx | 3 +- .../hooks/useStageLabels.ts | 9 ++++-- .../metaData/helpers/customLabels/index.ts | 2 ++ .../metaData/helpers/customLabels/tLabel.ts | 30 +++++++++++++++++++ .../metaData/helpers/customLabels/useLabel.ts | 9 ++++-- .../capture-core/metaData/helpers/index.ts | 10 ++++++- .../capture-core/metaData/index.ts | 2 ++ .../programStage/ProgramStageFactory.ts | 12 ++++++-- .../utils/capitalizeFirstLetter.ts | 11 +++++++ 11 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts create mode 100644 src/core_modules/capture-core/utils/capitalizeFirstLetter.ts diff --git a/i18n/en.pot b/i18n/en.pot index ea6ed29567..f0ec511df6 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-13T09:56:16.886Z\n" -"PO-Revision-Date: 2026-08-13T09:56:16.886Z\n" +"POT-Creation-Date: 2026-08-15T18:18:21.625Z\n" +"PO-Revision-Date: 2026-08-15T18:18:21.625Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1446,9 +1446,6 @@ msgstr "Enrollment date" msgid "Incident date" msgstr "Incident date" -msgid "Enrollment widget could not be loaded. Please try again later" -msgstr "Enrollment widget could not be loaded. Please try again later" - msgid "Follow-up" msgstr "Follow-up" diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 29b02e6023..3bff464153 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -17,7 +17,12 @@ import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes } from '../../metaData'; +import { + dataElementTypes, + useTermLabel, + tLabel, + capitalizeFirstLetter, +} from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -82,6 +87,7 @@ const WidgetEnrollmentPlain = ({ onAccessLostFromTransfer, }: PlainProps & WithStyles) => { const { programWriteAccess, showWidgetBadge } = useEnrollmentAccessContext(); + const enrollmentLabel = useTermLabel('enrollment'); const enrollmentReadOnly = readOnlyMode || !programWriteAccess; const [open, setOpenStatus] = useState(true); const { fromServerDate } = useTimeZoneConversion(); @@ -100,7 +106,7 @@ const WidgetEnrollmentPlain = ({ - {i18n.t('Enrollment')} + {capitalizeFirstLetter(enrollmentLabel)} {showWidgetBadge && (
setOpenStatus(false), [setOpenStatus])} open={open} > - {initError && ( + {true && (
- {i18n.t('Enrollment widget could not be loaded. Please try again later')} + {tLabel('{{enrollmentLabel}} widget could not be loaded. Please try again later', + { enrollmentLabel })}
)} {loading && } diff --git a/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx b/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx index e27abe562d..ef5a063a4d 100644 --- a/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx @@ -11,6 +11,7 @@ import { } from 'capture-core/components/FormFields/New'; import { isValidDate, isValidPeriod } from 'capture-core/utils/validation/validators/form'; import { hasValue } from 'capture-core-utils/validators/form'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { systemSettingsStore } from '../../../metaDataMemoryStores'; import labelTypeClasses from './dataEntryFieldLabels.module.css'; import { InfoBox } from '../InfoBox'; @@ -133,7 +134,7 @@ const ScheduleDatePlain = ({ /> :
- {displayDueDateLabel ?? i18n.t('Schedule date / Due date', { + {displayDueDateLabel ? capitalizeFirstLetter(displayDueDateLabel) : i18n.t('Schedule date / Due date', { interpolation: { escapeValue: false }, }, )} diff --git a/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts b/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts index 2cdbd0a2ee..2955ca81c8 100644 --- a/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts +++ b/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { getUserMetadataStorageController, USER_METADATA_STORES } from '../../../storageControllers'; import { useIndexedDBQuery } from '../../../utils/reactQueryHelpers'; @@ -23,8 +24,12 @@ export const useStageLabels = (programId: string, programStageId?: string) => { ); return { - scheduledLabel: data?.displayDueDateLabel ?? i18n.t('Scheduled date'), - occurredLabel: data?.displayExecutionDateLabel ?? i18n.t('Report date'), + scheduledLabel: data?.displayDueDateLabel + ? capitalizeFirstLetter(data.displayDueDateLabel) + : i18n.t('Scheduled date'), + occurredLabel: data?.displayExecutionDateLabel + ? capitalizeFirstLetter(data.displayExecutionDateLabel) + : i18n.t('Report date'), isLoading: isInitialLoading, error, }; 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 08d6759b09..9c9de5821a 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -1,3 +1,5 @@ export { CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel } from './customLabels'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; export { getTermLabel, useTermLabel } from './useLabel'; +export { tLabel } from './tLabel'; +export { capitalizeFirstLetter } from '../../../utils/capitalizeFirstLetter'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts new file mode 100644 index 0000000000..172b02b9c5 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts @@ -0,0 +1,30 @@ +import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from '../../../utils/capitalizeFirstLetter'; + +const getRawTranslation = (key: string): string => + (i18n as any).getResource((i18n as any).language, 'default', key) + ?? (i18n as any).getResource('en', 'default', key) + ?? key; + +const startsWithVar = (raw: string, varName: string): boolean => { + const trimmed = raw.trimStart(); + return trimmed.startsWith(`{{${varName}}}`) || trimmed.startsWith(`{{${varName},`); +}; + +/** + * Drop-in replacement for i18n.t() when the string contains custom label variables. + * Automatically capitalizes a variable's value when it appears as the first word + * in the translated string — without requiring any changes to translation files. + */ +export const tLabel = (key: string, options: Record = {}): string => { + const raw = getRawTranslation(key); + const processedOptions = { ...options }; + + for (const [varName, value] of Object.entries(options)) { + if (typeof value === 'string' && startsWithVar(raw, varName)) { + processedOptions[varName] = capitalizeFirstLetter(value); + } + } + + return i18n.t(key, { ...processedOptions, interpolation: { escapeValue: false } }); +}; 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 9f0402fa65..dae529d57f 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts @@ -25,8 +25,8 @@ const resolve = ( ): 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](); + const customLabel = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); + return customLabel ?? defaults[key](); }; /** @@ -51,5 +51,8 @@ export const useTermLabel = ( const { programId, stageId, plural } = options; const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); const id = programId ?? currentProgramId; - return useMemo(() => resolve(id, key, { stageId, plural }), [id, key, stageId, plural]); + 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 c46d3adf0e..94a88e45ee 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -17,5 +17,13 @@ export { getScopeInfo } from './getScopeInfo'; export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; -export { CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel, getTermLabel, useTermLabel } from './customLabels'; +export { + CUSTOM_LABEL_FIELDS, + extractCustomLabels, + resolveLabel, + getTermLabel, + useTermLabel, + tLabel, + capitalizeFirstLetter, +} 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 139fb995a4..a098e321b4 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -45,5 +45,7 @@ export { resolveLabel, getTermLabel, useTermLabel, + tLabel, + capitalizeFirstLetter, } from './helpers'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts index 6a886b8f72..8e10d40c4d 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts @@ -241,8 +241,16 @@ export class ProgramStageFactory { _form.description = cachedProgramStage.description; _form.featureType = ProgramStageFactory._getFeatureType(cachedProgramStage); _form.access = cachedProgramStage.access; - _form.addLabel({ id: 'occurredAt', label: cachedProgramStage.displayExecutionDateLabel || 'Report date' }); - _form.addLabel({ id: 'scheduledAt', label: cachedProgramStage.displayDueDateLabel || 'Scheduled date' }); + const executionLabel = cachedProgramStage.displayExecutionDateLabel; + const dueDateLabel = cachedProgramStage.displayDueDateLabel; + _form.addLabel({ + id: 'occurredAt', + label: executionLabel ? capitalizeFirstLetter(executionLabel) : 'Report date', + }); + _form.addLabel({ + id: 'scheduledAt', + label: dueDateLabel ? capitalizeFirstLetter(dueDateLabel) : 'Scheduled date', + }); _form.validationStrategy = cachedProgramStage.validationStrategy && camelCaseUppercaseString(cachedProgramStage.validationStrategy); diff --git a/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts b/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts new file mode 100644 index 0000000000..cf3370402a --- /dev/null +++ b/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts @@ -0,0 +1,11 @@ +import i18n from '@dhis2/d2-i18n'; + +export const capitalizeFirstLetter = (str: string): string => { + if (!str) return str; + const locale = (i18n as any).language ?? 'en'; + try { + return str.charAt(0).toLocaleUpperCase(locale) + str.slice(1); + } catch { + return str.charAt(0).toUpperCase() + str.slice(1); + } +}; From 89b75c7e14366034438e3a6b8f90feda39af9214 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:42:26 +0000 Subject: [PATCH 26/57] fix: temp clean up --- cypress/e2e/ScopeSelector/ScopeSelector.js | 2 +- .../WidgetEnrollmentNote/index.js | 2 +- i18n/en.pot | 25 ++++- .../Relationships/Relationships.component.tsx | 9 +- .../WidgetEnrollment.component.tsx | 11 +-- .../WidgetProfile/hooks/useApiProgram.ts | 13 +-- .../metaData/helpers/customLabels.ts | 97 +++++++++++++++++++ .../helpers/customLabels/customLabels.ts | 51 ---------- .../metaData/helpers/customLabels/index.ts | 5 - .../metaData/helpers/customLabels/useLabel.ts | 58 ----------- .../capture-core/metaData/helpers/index.ts | 2 - .../capture-core/metaData/index.ts | 2 - .../helpers/customLabels => utils}/tLabel.ts | 7 +- 13 files changed, 136 insertions(+), 148 deletions(-) create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/index.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts rename src/core_modules/capture-core/{metaData/helpers/customLabels => utils}/tLabel.ts (72%) diff --git a/cypress/e2e/ScopeSelector/ScopeSelector.js b/cypress/e2e/ScopeSelector/ScopeSelector.js index b3298a75e6..90e7a8e300 100644 --- a/cypress/e2e/ScopeSelector/ScopeSelector.js +++ b/cypress/e2e/ScopeSelector/ScopeSelector.js @@ -284,7 +284,7 @@ And('you see the enrollment event Edit page but there is no org unit id in the u And('you see the enrollment event New page but there is no stage id in the url', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/#/enrollmentEventNew?enrollmentId=Aemr3Q02aqV&orgUnitId=DiszpKrYNg8&programId=ur1Edk5Oe2n&teiId=eUTmQGull6H`); - cy.contains('Choose a stage for a new event'); + cy.contains('Choose a program stage for a new event'); }); And('you see the enrollment page without org unit in the url', () => { diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js index 67f9b5df5f..e96b83be28 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js @@ -3,7 +3,7 @@ import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; const timeStamp = Math.round((new Date()).getTime() / 1000); Then('the stages and events should be loaded', () => { - cy.contains('Stages and Events').should('exist'); + cy.contains('Program stages and events').should('exist'); }); When(/^you fill in the note: (.*)$/, (note) => { diff --git a/i18n/en.pot b/i18n/en.pot index c79ff5717a..95b0ec811f 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-17T07:54:36.289Z\n" -"PO-Revision-Date: 2026-08-17T07:54:36.289Z\n" +"POT-Creation-Date: 2026-08-28T13:42:27.818Z\n" +"PO-Revision-Date: 2026-08-28T13:42:27.818Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -2239,24 +2239,45 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" +msgid "enrollments" +msgstr "enrollments" + msgid "program stage" msgstr "program stage" +msgid "program stages" +msgstr "program stages" + msgid "note" msgstr "note" +msgid "notes" +msgstr "notes" + msgid "relationship" msgstr "relationship" +msgid "relationships" +msgstr "relationships" + msgid "attribute" msgstr "attribute" +msgid "attributes" +msgstr "attributes" + msgid "organisation unit" msgstr "organisation unit" +msgid "organisation units" +msgstr "organisation units" + msgid "follow-up" msgstr "follow-up" +msgid "follow-ups" +msgstr "follow-ups" + msgid "Program not found" msgstr "Program not found" 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 7b8ccb42e9..69c2596a9f 100644 --- a/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx +++ b/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx @@ -63,10 +63,9 @@ const styles: Readonly = (theme: any) => ({ }, }); -const getFromName = (entityType: string) => { - if (entityType === 'PROGRAM_STAGE_INSTANCE') return i18n.t('This event'); - return undefined; -}; +const getFromNames = () => ({ + PROGRAM_STAGE_INSTANCE: i18n.t('This event'), +}); type PlainProps = { relationships: Array; @@ -106,7 +105,7 @@ class RelationshipsPlain extends React.Component { const { onRenderConnectedEntity } = this.props; if (entity.id === this.props.currentEntityId) { - return getFromName(entity.type); + return getFromNames()[entity.type]; } return onRenderConnectedEntity ? onRenderConnectedEntity(entity) : entity.name; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 3bff464153..48fb186023 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -17,12 +17,9 @@ import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { - dataElementTypes, - useTermLabel, - tLabel, - capitalizeFirstLetter, -} from '../../metaData'; +import { dataElementTypes, useTermLabel } from '../../metaData'; +import { tLabel } from '../../utils/tLabel'; +import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -121,7 +118,7 @@ const WidgetEnrollmentPlain = ({ onClose={useCallback(() => setOpenStatus(false), [setOpenStatus])} open={open} > - {true && ( + {initError && (
{tLabel('{{enrollmentLabel}} widget could not be loaded. Please try again later', { enrollmentLabel })} 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 97fae559f4..ada6c9dde0 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useApiProgram.ts @@ -1,12 +1,7 @@ import { useMemo } from 'react'; import { useDataQuery } from '@dhis2/app-runtime'; -const trackedEntityTypeFields = - 'id,access,displayName,allowAuditLog,minAttributesRequiredToSearch,featureType,' + - 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + - 'translations[property,locale,value]'; - -const buildFields = (): string => +const fields = 'id,version,displayName,displayShortName,description,programType,style,minAttributesRequiredToSearch,' + 'enrollmentDateLabel,incidentDateLabel,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture,' + 'displayIncidentDate,access[*],' + @@ -28,7 +23,9 @@ const buildFields = (): string => '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}],` + + 'trackedEntityType[id,access,displayName,allowAuditLog,minAttributesRequiredToSearch,featureType,' + + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + + 'translations[property,locale,value]],' + 'userRoles[id,displayName]'; export const useApiProgram = (programId: string) => { @@ -39,7 +36,7 @@ export const useApiProgram = (programId: string) => { resource: 'programs', id: programId, params: { - fields: buildFields(), + fields, }, }, }), diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts new file mode 100644 index 0000000000..d50506e143 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -0,0 +1,97 @@ +import i18n from '@dhis2/d2-i18n'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { programCollection } from '../../metaDataMemoryStores'; + +type CustomLabelField = { + field?: string, + pluralField?: string, +}; + +export const CUSTOM_LABEL_FIELDS = { + enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, + event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, + programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, + note: { field: 'displayNoteLabel' }, + relationship: { field: 'displayRelationshipLabel' }, + attribute: { field: 'displayTrackedEntityAttributeLabel' }, + orgUnit: { field: 'displayOrgUnitLabel' }, + followUp: { field: 'displayFollowUpLabel' }, +} 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( + new Set( + Object.values(CUSTOM_LABEL_FIELDS) + .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) + .filter((field): field is string => Boolean(field)), + ), +); + +export const extractCustomLabels = (cached: Record): CustomLabels => { + const labels: CustomLabels = {}; + allFields.forEach((field) => { + if (cached[field]) labels[field] = cached[field]; + }); + return labels; +}; + +type LabelSource = CustomLabels | undefined | null; + +export const resolveLabel = ( + sources: LabelSource | Array, + key: CustomLabelKey, + { plural = false }: LabelOptions = {}, +): string | undefined => { + const term = CUSTOM_LABEL_FIELDS[key] as CustomLabelField; + const list = Array.isArray(sources) ? sources : [sources]; + const pick = (field?: string) => (field ? list.find(s => s?.[field])?.[field] : undefined); + return pick(plural ? term.pluralField : term.field); +}; + +const defaults: Record string; plural: () => string }> = { + enrollment: { singular: () => i18n.t('enrollment'), plural: () => i18n.t('enrollments') }, + event: { singular: () => i18n.t('event'), plural: () => i18n.t('events') }, + programStage: { singular: () => i18n.t('program stage'), plural: () => i18n.t('program stages') }, + note: { singular: () => i18n.t('note'), plural: () => i18n.t('notes') }, + relationship: { singular: () => i18n.t('relationship'), plural: () => i18n.t('relationships') }, + attribute: { singular: () => i18n.t('attribute'), plural: () => i18n.t('attributes') }, + orgUnit: { singular: () => i18n.t('organisation unit'), plural: () => i18n.t('organisation units') }, + followUp: { singular: () => i18n.t('follow-up'), plural: () => i18n.t('follow-ups') }, +}; + +type TermLabelOptions = LabelOptions & { stageId?: string; programId?: string }; + +const resolveTerm = ( + programId: string | undefined, + key: CustomLabelKey, + { stageId, plural = false }: TermLabelOptions, +): string => { + const program = programId ? programCollection.get(programId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + const customLabel = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); + if (customLabel) return customLabel; + return plural ? defaults[key].plural() : defaults[key].singular(); +}; + +export const getTermLabel = ( + programId: string | undefined, + key: CustomLabelKey, + options: TermLabelOptions = {}, +): string => resolveTerm(programId, key, options); + +export const useTermLabel = ( + key: CustomLabelKey, + options: TermLabelOptions = {}, +): string => { + const { programId, stageId, plural } = options; + const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); + const id = programId ?? currentProgramId; + return useMemo( + () => resolveTerm(id, key, { stageId, plural }), + [id, key, stageId, plural], + ); +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts deleted file mode 100644 index bf60ed5c72..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ /dev/null @@ -1,51 +0,0 @@ -type CustomLabelField = { - field?: string, - pluralField?: string, -}; - -export const CUSTOM_LABEL_FIELDS = { - enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, - event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, - programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, - note: { field: 'displayNoteLabel' }, - relationship: { field: 'displayRelationshipLabel' }, - attribute: { field: 'displayTrackedEntityAttributeLabel' }, - orgUnit: { field: 'displayOrgUnitLabel' }, - followUp: { field: 'displayFollowUpLabel' }, -} 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( - new Set( - Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) - .filter((field): field is string => Boolean(field)), - ), -); - -export const extractCustomLabels = (cached: Record): CustomLabels => { - const labels: CustomLabels = {}; - allFields.forEach((field) => { - if (cached[field]) labels[field] = cached[field]; - }); - return labels; -}; - -type LabelSource = CustomLabels | undefined | null; - -export const resolveLabel = ( - sources: LabelSource | Array, - key: CustomLabelKey, - { plural = false }: LabelOptions = {}, -): string | undefined => { - const term = CUSTOM_LABEL_FIELDS[key] as CustomLabelField; - 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); - } - return pick(term.field); -}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts deleted file mode 100644 index 9c9de5821a..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel } from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { getTermLabel, useTermLabel } from './useLabel'; -export { tLabel } from './tLabel'; -export { capitalizeFirstLetter } from '../../../utils/capitalizeFirstLetter'; 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 dae529d57f..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts +++ /dev/null @@ -1,58 +0,0 @@ -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'; - -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'), -}; - -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; - const customLabel = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - return customLabel ?? defaults[key](); -}; - -/** - * 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, - options: TermLabelOptions & { programId?: string } = {}, -): string => { - const { programId, stageId, plural } = options; - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - 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 94a88e45ee..ac10105196 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -23,7 +23,5 @@ export { resolveLabel, getTermLabel, useTermLabel, - tLabel, - capitalizeFirstLetter, } 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 a098e321b4..139fb995a4 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -45,7 +45,5 @@ export { resolveLabel, getTermLabel, useTermLabel, - tLabel, - capitalizeFirstLetter, } from './helpers'; export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts b/src/core_modules/capture-core/utils/tLabel.ts similarity index 72% rename from src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts rename to src/core_modules/capture-core/utils/tLabel.ts index 172b02b9c5..077c37d69c 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/tLabel.ts +++ b/src/core_modules/capture-core/utils/tLabel.ts @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from '../../../utils/capitalizeFirstLetter'; +import { capitalizeFirstLetter } from './capitalizeFirstLetter'; const getRawTranslation = (key: string): string => (i18n as any).getResource((i18n as any).language, 'default', key) @@ -11,11 +11,6 @@ const startsWithVar = (raw: string, varName: string): boolean => { return trimmed.startsWith(`{{${varName}}}`) || trimmed.startsWith(`{{${varName},`); }; -/** - * Drop-in replacement for i18n.t() when the string contains custom label variables. - * Automatically capitalizes a variable's value when it appears as the first word - * in the translated string — without requiring any changes to translation files. - */ export const tLabel = (key: string, options: Record = {}): string => { const raw = getRawTranslation(key); const processedOptions = { ...options }; From a2dd40f0ed646860bd57c7618036f967b267e697 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:37:09 +0000 Subject: [PATCH 27/57] feat: replace tLabel with tCustomTerm --- i18n/en.pot | 19 +-- .../WidgetEnrollment.component.tsx | 8 +- .../metaData/helpers/customLabels.ts | 108 +++++++++++------- .../capture-core/metaData/helpers/index.ts | 1 - .../capture-core/metaData/index.ts | 1 - .../capture-core/utils/tCustomTerm.ts | 37 ++++++ src/core_modules/capture-core/utils/tLabel.ts | 25 ---- 7 files changed, 108 insertions(+), 91 deletions(-) create mode 100644 src/core_modules/capture-core/utils/tCustomTerm.ts delete mode 100644 src/core_modules/capture-core/utils/tLabel.ts diff --git a/i18n/en.pot b/i18n/en.pot index 95b0ec811f..bcfc414f2d 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-28T13:42:27.818Z\n" -"PO-Revision-Date: 2026-08-28T13:42:27.818Z\n" +"POT-Creation-Date: 2026-08-28T14:37:10.874Z\n" +"PO-Revision-Date: 2026-08-28T14:37:10.874Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -2251,33 +2251,18 @@ msgstr "program stages" msgid "note" msgstr "note" -msgid "notes" -msgstr "notes" - msgid "relationship" msgstr "relationship" -msgid "relationships" -msgstr "relationships" - msgid "attribute" msgstr "attribute" -msgid "attributes" -msgstr "attributes" - msgid "organisation unit" msgstr "organisation unit" -msgid "organisation units" -msgstr "organisation units" - msgid "follow-up" msgstr "follow-up" -msgid "follow-ups" -msgstr "follow-ups" - msgid "Program not found" msgstr "Program not found" diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 48fb186023..2949fd4e17 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -18,7 +18,7 @@ import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDo import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; import { dataElementTypes, useTermLabel } from '../../metaData'; -import { tLabel } from '../../utils/tLabel'; +import { tCustomTerm } from '../../utils/tCustomTerm'; import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; @@ -120,8 +120,10 @@ const WidgetEnrollmentPlain = ({ > {initError && (
- {tLabel('{{enrollmentLabel}} widget could not be loaded. Please try again later', - { enrollmentLabel })} + {tCustomTerm( + '{{enrollmentLabel}} widget could not be loaded. Please try again later', + { enrollmentLabel }, + )}
)} {loading && } diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index d50506e143..aa7964603b 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -3,41 +3,70 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { programCollection } from '../../metaDataMemoryStores'; -type CustomLabelField = { - field?: string, - pluralField?: string, +type LabelConfig = { + field: string; + pluralField?: string; + singular: () => string; + plural?: () => string; }; -export const CUSTOM_LABEL_FIELDS = { - enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, - event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, - programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, - note: { field: 'displayNoteLabel' }, - relationship: { field: 'displayRelationshipLabel' }, - attribute: { field: 'displayTrackedEntityAttributeLabel' }, - orgUnit: { field: 'displayOrgUnitLabel' }, - followUp: { field: 'displayFollowUpLabel' }, -} as const satisfies { [key: string]: CustomLabelField }; +const asLabels = (labels: Record) => labels; -export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; +const LABELS = asLabels({ + enrollment: { + field: 'displayEnrollmentLabel', + pluralField: 'displayEnrollmentsLabel', + singular: () => i18n.t('enrollment'), + plural: () => i18n.t('enrollments'), + }, + event: { + field: 'displayEventLabel', + pluralField: 'displayEventsLabel', + singular: () => i18n.t('event'), + plural: () => i18n.t('events'), + }, + programStage: { + field: 'displayProgramStageLabel', + pluralField: 'displayProgramStagesLabel', + singular: () => i18n.t('program stage'), + plural: () => i18n.t('program stages'), + }, + note: { + field: 'displayNoteLabel', + singular: () => i18n.t('note'), + }, + relationship: { + field: 'displayRelationshipLabel', + singular: () => i18n.t('relationship'), + }, + attribute: { + field: 'displayTrackedEntityAttributeLabel', + singular: () => i18n.t('attribute'), + }, + orgUnit: { + field: 'displayOrgUnitLabel', + singular: () => i18n.t('organisation unit'), + }, + followUp: { + field: 'displayFollowUpLabel', + singular: () => i18n.t('follow-up'), + }, +}); + +export type CustomLabelKey = keyof typeof LABELS; export type CustomLabels = Record; export type LabelOptions = { plural?: boolean }; -const allFields: Array = Array.from( - new Set( - Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) - .filter((field): field is string => Boolean(field)), - ), +const ALL_FIELD_NAMES = Object.values(LABELS).flatMap( + ({ field, pluralField }) => (pluralField ? [field, pluralField] : [field]), ); -export const extractCustomLabels = (cached: Record): CustomLabels => { - const labels: CustomLabels = {}; - allFields.forEach((field) => { - if (cached[field]) labels[field] = cached[field]; - }); - return labels; -}; +export const extractCustomLabels = (cached: Record): CustomLabels => + Object.fromEntries( + ALL_FIELD_NAMES + .filter(field => typeof cached[field] === 'string') + .map(field => [field, cached[field] as string]), + ); type LabelSource = CustomLabels | undefined | null; @@ -46,21 +75,11 @@ export const resolveLabel = ( key: CustomLabelKey, { plural = false }: LabelOptions = {}, ): string | undefined => { - const term = CUSTOM_LABEL_FIELDS[key] as CustomLabelField; + const { field, pluralField } = LABELS[key]; + const target = plural ? pluralField : field; + if (!target) return undefined; const list = Array.isArray(sources) ? sources : [sources]; - const pick = (field?: string) => (field ? list.find(s => s?.[field])?.[field] : undefined); - return pick(plural ? term.pluralField : term.field); -}; - -const defaults: Record string; plural: () => string }> = { - enrollment: { singular: () => i18n.t('enrollment'), plural: () => i18n.t('enrollments') }, - event: { singular: () => i18n.t('event'), plural: () => i18n.t('events') }, - programStage: { singular: () => i18n.t('program stage'), plural: () => i18n.t('program stages') }, - note: { singular: () => i18n.t('note'), plural: () => i18n.t('notes') }, - relationship: { singular: () => i18n.t('relationship'), plural: () => i18n.t('relationships') }, - attribute: { singular: () => i18n.t('attribute'), plural: () => i18n.t('attributes') }, - orgUnit: { singular: () => i18n.t('organisation unit'), plural: () => i18n.t('organisation units') }, - followUp: { singular: () => i18n.t('follow-up'), plural: () => i18n.t('follow-ups') }, + return list.find(source => source?.[target])?.[target]; }; type TermLabelOptions = LabelOptions & { stageId?: string; programId?: string }; @@ -72,9 +91,10 @@ const resolveTerm = ( ): string => { const program = programId ? programCollection.get(programId) : undefined; const stage = program && stageId ? program.getStage(stageId) : undefined; - const customLabel = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - if (customLabel) return customLabel; - return plural ? defaults[key].plural() : defaults[key].singular(); + const custom = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); + if (custom) return custom; + if (plural) return LABELS[key].plural?.() ?? `${key}s`; + return LABELS[key].singular(); }; export const getTermLabel = ( diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index ac10105196..b4158ea1a9 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -18,7 +18,6 @@ export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { - CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel, getTermLabel, diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 139fb995a4..1e8e7461b6 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -40,7 +40,6 @@ export { getProgramThrowIfNotFound, getProgramAndStageForEventProgram, getEventProgramEventAccess, - CUSTOM_LABEL_FIELDS, extractCustomLabels, resolveLabel, getTermLabel, diff --git a/src/core_modules/capture-core/utils/tCustomTerm.ts b/src/core_modules/capture-core/utils/tCustomTerm.ts new file mode 100644 index 0000000000..dcef43f076 --- /dev/null +++ b/src/core_modules/capture-core/utils/tCustomTerm.ts @@ -0,0 +1,37 @@ +import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from './capitalizeFirstLetter'; + +type I18nInternal = { + getResource: (lang: string, ns: string, key: string) => string | undefined; + language: string; +}; +const internal = i18n as unknown as I18nInternal; + +const getRawTranslation = (key: string): string => + internal.getResource(internal.language, 'default', key) + ?? internal.getResource('en', 'default', key) + ?? key; + +const startsWithVar = (raw: string, varName: string): boolean => { + const trimmed = raw.trimStart(); + return trimmed.startsWith(`{{${varName}}}`) || trimmed.startsWith(`{{${varName},`); +}; + +export const tCustomTerm = (key: string, options: Record = {}): string => { + const raw = getRawTranslation(key); + const { interpolation, ...values } = options; + + const cased = Object.fromEntries( + Object.entries(values).map(([name, value]) => [ + name, + typeof value === 'string' && startsWithVar(raw, name) + ? capitalizeFirstLetter(value) + : value, + ]), + ); + + return i18n.t(key, { + ...cased, + interpolation: { escapeValue: false, ...(interpolation as Record ?? {}) }, + }); +}; diff --git a/src/core_modules/capture-core/utils/tLabel.ts b/src/core_modules/capture-core/utils/tLabel.ts deleted file mode 100644 index 077c37d69c..0000000000 --- a/src/core_modules/capture-core/utils/tLabel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from './capitalizeFirstLetter'; - -const getRawTranslation = (key: string): string => - (i18n as any).getResource((i18n as any).language, 'default', key) - ?? (i18n as any).getResource('en', 'default', key) - ?? key; - -const startsWithVar = (raw: string, varName: string): boolean => { - const trimmed = raw.trimStart(); - return trimmed.startsWith(`{{${varName}}}`) || trimmed.startsWith(`{{${varName},`); -}; - -export const tLabel = (key: string, options: Record = {}): string => { - const raw = getRawTranslation(key); - const processedOptions = { ...options }; - - for (const [varName, value] of Object.entries(options)) { - if (typeof value === 'string' && startsWithVar(raw, varName)) { - processedOptions[varName] = capitalizeFirstLetter(value); - } - } - - return i18n.t(key, { ...processedOptions, interpolation: { escapeValue: false } }); -}; From cb9b19595a8243ba0b613e018592be4025793d3f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:17:38 +0000 Subject: [PATCH 28/57] feat: consolidate capitalizeFirstLetter utility and update imports --- i18n/en.pot | 4 +-- .../string/capitalizeFirstLetter.ts | 12 +++++-- .../capture-core/HOC/withCustomLabels.tsx | 26 +++++++++++++++ .../WidgetEnrollment.component.tsx | 2 +- .../utils/capitalizeFirstLetter.ts | 11 ------- .../capture-core/utils/tCustomTerm.ts | 32 +++++++++---------- 6 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 src/core_modules/capture-core/HOC/withCustomLabels.tsx delete mode 100644 src/core_modules/capture-core/utils/capitalizeFirstLetter.ts diff --git a/i18n/en.pot b/i18n/en.pot index bcfc414f2d..70ea1516ce 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-28T14:37:10.874Z\n" -"PO-Revision-Date: 2026-08-28T14:37:10.874Z\n" +"POT-Creation-Date: 2026-08-31T09:17:40.273Z\n" +"PO-Revision-Date: 2026-08-31T09:17:40.273Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts index 0184db6f88..fdbe04456c 100644 --- a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts +++ b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts @@ -1,5 +1,11 @@ +import i18n from '@dhis2/d2-i18n'; + export function capitalizeFirstLetter(text: string) { - const first = text.charAt(0).toLocaleUpperCase(); - const rest = text.slice(1); - return first + rest; + if (!text) return text; + const locale = (i18n as any).language ?? 'en'; + try { + return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1); + } catch { + return text.charAt(0).toUpperCase() + text.slice(1); + } } diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx new file mode 100644 index 0000000000..659b0957ad --- /dev/null +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; +import { useTermLabel } from '../metaData'; +import type { CustomLabelKey } from '../metaData/helpers/customLabels'; + +type LabelSpec = { + key: CustomLabelKey; + plural?: boolean; +}; + +type LabelSpecs = Record; + +type InjectedLabels = { [K in keyof S]: string }; + +export const withCustomLabels = + (specs: S) => +

>(WrappedComponent: React.ComponentType

>) => + (props: P) => { + const labels = Object.fromEntries( + Object.entries(specs).map(([propName, { key, plural }]) => [ + propName, + capitalizeFirstLetter(useTermLabel(key, { plural })), + ]), + ) as InjectedLabels; + return React.createElement(WrappedComponent, { ...props, ...labels }); + }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 2949fd4e17..43f61a5a59 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -11,6 +11,7 @@ import { import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; @@ -19,7 +20,6 @@ import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; import { dataElementTypes, useTermLabel } from '../../metaData'; import { tCustomTerm } from '../../utils/tCustomTerm'; -import { capitalizeFirstLetter } from '../../utils/capitalizeFirstLetter'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; diff --git a/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts b/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts deleted file mode 100644 index cf3370402a..0000000000 --- a/src/core_modules/capture-core/utils/capitalizeFirstLetter.ts +++ /dev/null @@ -1,11 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; - -export const capitalizeFirstLetter = (str: string): string => { - if (!str) return str; - const locale = (i18n as any).language ?? 'en'; - try { - return str.charAt(0).toLocaleUpperCase(locale) + str.slice(1); - } catch { - return str.charAt(0).toUpperCase() + str.slice(1); - } -}; diff --git a/src/core_modules/capture-core/utils/tCustomTerm.ts b/src/core_modules/capture-core/utils/tCustomTerm.ts index dcef43f076..1e9ceb81c0 100644 --- a/src/core_modules/capture-core/utils/tCustomTerm.ts +++ b/src/core_modules/capture-core/utils/tCustomTerm.ts @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from './capitalizeFirstLetter'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; type I18nInternal = { getResource: (lang: string, ns: string, key: string) => string | undefined; @@ -7,31 +7,31 @@ type I18nInternal = { }; const internal = i18n as unknown as I18nInternal; -const getRawTranslation = (key: string): string => +const getTranslatedTemplate = (key: string): string => internal.getResource(internal.language, 'default', key) ?? internal.getResource('en', 'default', key) ?? key; -const startsWithVar = (raw: string, varName: string): boolean => { - const trimmed = raw.trimStart(); - return trimmed.startsWith(`{{${varName}}}`) || trimmed.startsWith(`{{${varName},`); +type Options = { + interpolation?: Record; + [key: string]: unknown; }; -export const tCustomTerm = (key: string, options: Record = {}): string => { - const raw = getRawTranslation(key); +export const tCustomTerm = (key: string, options: Options = {}): string => { const { interpolation, ...values } = options; + const template = getTranslatedTemplate(key).trimStart(); - const cased = Object.fromEntries( - Object.entries(values).map(([name, value]) => [ - name, - typeof value === 'string' && startsWithVar(raw, name) - ? capitalizeFirstLetter(value) - : value, - ]), + const casedValues = Object.fromEntries( + Object.entries(values).map(([name, value]) => { + const variableIsAtSentenceStart = template.startsWith(`{{${name}}}`) + || template.startsWith(`{{${name},`); + const shouldCapitalize = typeof value === 'string' && variableIsAtSentenceStart; + return [name, shouldCapitalize ? capitalizeFirstLetter(value) : value]; + }), ); return i18n.t(key, { - ...cased, - interpolation: { escapeValue: false, ...(interpolation as Record ?? {}) }, + ...casedValues, + interpolation: { escapeValue: false, ...(interpolation ?? {}) }, }); }; From 3ead8dd2aa9d1477857d01c04a7a5fe9e3525b6a Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:15:49 +0000 Subject: [PATCH 29/57] fix: simplify interpolation handling in tCustomTerm function --- i18n/en.pot | 4 ++-- src/core_modules/capture-core/utils/tCustomTerm.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 70ea1516ce..b4c8422fb7 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-31T09:17:40.273Z\n" -"PO-Revision-Date: 2026-08-31T09:17:40.273Z\n" +"POT-Creation-Date: 2026-08-31T11:15:51.335Z\n" +"PO-Revision-Date: 2026-08-31T11:15:51.335Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/utils/tCustomTerm.ts b/src/core_modules/capture-core/utils/tCustomTerm.ts index 1e9ceb81c0..a1f46accbf 100644 --- a/src/core_modules/capture-core/utils/tCustomTerm.ts +++ b/src/core_modules/capture-core/utils/tCustomTerm.ts @@ -32,6 +32,6 @@ export const tCustomTerm = (key: string, options: Options = {}): string => { return i18n.t(key, { ...casedValues, - interpolation: { escapeValue: false, ...(interpolation ?? {}) }, + interpolation: { escapeValue: false, ...interpolation }, }); }; From d00b1b9a084e7929d0cf90221c8b9c0d34512ffc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:32:12 +0000 Subject: [PATCH 30/57] fix: update plural label fallback to use singular label for default --- i18n/en.pot | 4 ++-- .../capture-core/metaData/helpers/customLabels.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b4c8422fb7..510dfd455f 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-31T11:15:51.335Z\n" -"PO-Revision-Date: 2026-08-31T11:15:51.335Z\n" +"POT-Creation-Date: 2026-09-01T10:32:13.670Z\n" +"PO-Revision-Date: 2026-09-01T10:32:13.670Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index aa7964603b..caf195f551 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -93,7 +93,7 @@ const resolveTerm = ( const stage = program && stageId ? program.getStage(stageId) : undefined; const custom = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); if (custom) return custom; - if (plural) return LABELS[key].plural?.() ?? `${key}s`; + if (plural) return LABELS[key].plural?.() ?? LABELS[key].singular(); return LABELS[key].singular(); }; From a18315be57c4692935f9c47564fec7097d75c99c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:08:56 +0000 Subject: [PATCH 31/57] fix: update getTermLabel to correctly pass programId from options --- i18n/en.pot | 4 ++-- .../capture-core/metaData/helpers/customLabels.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 510dfd455f..69bf7d9e86 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-01T10:32:13.670Z\n" -"PO-Revision-Date: 2026-09-01T10:32:13.670Z\n" +"POT-Creation-Date: 2026-09-02T08:08:57.448Z\n" +"PO-Revision-Date: 2026-09-02T08:08:57.448Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index caf195f551..6aabb86c77 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -98,10 +98,9 @@ const resolveTerm = ( }; export const getTermLabel = ( - programId: string | undefined, key: CustomLabelKey, - options: TermLabelOptions = {}, -): string => resolveTerm(programId, key, options); + options: TermLabelOptions & { programId: string }, +): string => resolveTerm(options.programId, key, options); export const useTermLabel = ( key: CustomLabelKey, From 0e6a407a12f6848f9e120923aa65c87bb6df2565 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:12:20 +0000 Subject: [PATCH 32/57] fix: enhance withCustomLabels to include programId and stageId in label retrieval --- i18n/en.pot | 4 ++-- src/core_modules/capture-core/HOC/withCustomLabels.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 69bf7d9e86..7b2a45a89e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T08:08:57.448Z\n" -"PO-Revision-Date: 2026-09-02T08:08:57.448Z\n" +"POT-Creation-Date: 2026-09-02T17:12:22.225Z\n" +"PO-Revision-Date: 2026-09-02T17:12:22.225Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx index 659b0957ad..d7ebf4d698 100644 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -15,11 +15,12 @@ type InjectedLabels = { [K in keyof S]: string }; export const withCustomLabels = (specs: S) =>

>(WrappedComponent: React.ComponentType

>) => - (props: P) => { + (props: P & { programId?: string; stageId?: string }) => { + const { programId, stageId } = props; const labels = Object.fromEntries( Object.entries(specs).map(([propName, { key, plural }]) => [ propName, - capitalizeFirstLetter(useTermLabel(key, { plural })), + capitalizeFirstLetter(useTermLabel(key, { programId, stageId, plural })), ]), ) as InjectedLabels; return React.createElement(WrappedComponent, { ...props, ...labels }); From 226b2dc43de69eecb262ec922d4df1e6d5210bda Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:48:31 +0000 Subject: [PATCH 33/57] fix: update TermLabelOptions to allow null values for programId and stageId --- i18n/en.pot | 4 ++-- .../capture-core/metaData/helpers/customLabels.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7b2a45a89e..4a29719dbc 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T17:12:22.225Z\n" -"PO-Revision-Date: 2026-09-02T17:12:22.225Z\n" +"POT-Creation-Date: 2026-09-02T18:48:32.639Z\n" +"PO-Revision-Date: 2026-09-02T18:48:32.639Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index 6aabb86c77..6142bf5884 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -82,10 +82,10 @@ export const resolveLabel = ( return list.find(source => source?.[target])?.[target]; }; -type TermLabelOptions = LabelOptions & { stageId?: string; programId?: string }; +type TermLabelOptions = LabelOptions & { stageId?: string | null; programId?: string | null }; const resolveTerm = ( - programId: string | undefined, + programId: string | null | undefined, key: CustomLabelKey, { stageId, plural = false }: TermLabelOptions, ): string => { From 790e301cffc590803ef4eb7554898cd1faa28d2a Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:00:17 +0000 Subject: [PATCH 34/57] feat: implement customTerms for improved i18n handling and remove tCustomTerm --- i18n/en.pot | 7 +++- .../WidgetEnrollment.component.tsx | 4 +- .../capture-core/utils/customTerms.ts | 41 +++++++++++++++++++ .../capture-core/utils/tCustomTerm.ts | 37 ----------------- 4 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 src/core_modules/capture-core/utils/customTerms.ts delete mode 100644 src/core_modules/capture-core/utils/tCustomTerm.ts diff --git a/i18n/en.pot b/i18n/en.pot index 4a29719dbc..0f3c882188 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T18:48:32.639Z\n" -"PO-Revision-Date: 2026-09-02T18:48:32.639Z\n" +"POT-Creation-Date: 2026-09-02T19:00:19.764Z\n" +"PO-Revision-Date: 2026-09-02T19:00:19.764Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1446,6 +1446,9 @@ msgstr "Enrollment date" msgid "Incident date" msgstr "Incident date" +msgid "{{enrollmentLabel}} widget could not be loaded. Please try again later" +msgstr "{{enrollmentLabel}} widget could not be loaded. Please try again later" + msgid "Follow-up" msgstr "Follow-up" diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 43f61a5a59..78830c7d04 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -19,12 +19,12 @@ import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDo import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; import { dataElementTypes, useTermLabel } from '../../metaData'; -import { tCustomTerm } from '../../utils/tCustomTerm'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; import { Actions } from './Actions'; import { MiniMap } from './MiniMap'; +import { customTerms } from '../../utils/customTerms'; const styles = { enrollment: { @@ -120,7 +120,7 @@ const WidgetEnrollmentPlain = ({ > {initError && (

- {tCustomTerm( + {customTerms.i18n.t( '{{enrollmentLabel}} widget could not be loaded. Please try again later', { enrollmentLabel }, )} diff --git a/src/core_modules/capture-core/utils/customTerms.ts b/src/core_modules/capture-core/utils/customTerms.ts new file mode 100644 index 0000000000..29600a0028 --- /dev/null +++ b/src/core_modules/capture-core/utils/customTerms.ts @@ -0,0 +1,41 @@ +import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; + +type I18nInternal = { + getResource: (lang: string, ns: string, key: string) => string | undefined; + language: string; +}; +const internal = i18n as unknown as I18nInternal; + +const getTranslatedTemplate = (key: string): string => + internal.getResource(internal.language, 'default', key) + ?? internal.getResource('en', 'default', key) + ?? key; + +type Options = { + interpolation?: Record; + [key: string]: unknown; +}; + +export const customTerms = { + i18n: { + t: (key: string, options: Options = {}): string => { + const { interpolation, ...values } = options; + const template = getTranslatedTemplate(key).trimStart(); + + const casedValues = Object.fromEntries( + Object.entries(values).map(([name, value]) => { + const variableIsAtSentenceStart = template.startsWith(`{{${name}}}`) + || template.startsWith(`{{${name},`); + const shouldCapitalize = typeof value === 'string' && variableIsAtSentenceStart; + return [name, shouldCapitalize ? capitalizeFirstLetter(value) : value]; + }), + ); + + return i18n.t(key, { + ...casedValues, + interpolation: { escapeValue: false, ...interpolation }, + }); + }, + }, +}; diff --git a/src/core_modules/capture-core/utils/tCustomTerm.ts b/src/core_modules/capture-core/utils/tCustomTerm.ts deleted file mode 100644 index a1f46accbf..0000000000 --- a/src/core_modules/capture-core/utils/tCustomTerm.ts +++ /dev/null @@ -1,37 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; - -type I18nInternal = { - getResource: (lang: string, ns: string, key: string) => string | undefined; - language: string; -}; -const internal = i18n as unknown as I18nInternal; - -const getTranslatedTemplate = (key: string): string => - internal.getResource(internal.language, 'default', key) - ?? internal.getResource('en', 'default', key) - ?? key; - -type Options = { - interpolation?: Record; - [key: string]: unknown; -}; - -export const tCustomTerm = (key: string, options: Options = {}): string => { - const { interpolation, ...values } = options; - const template = getTranslatedTemplate(key).trimStart(); - - const casedValues = Object.fromEntries( - Object.entries(values).map(([name, value]) => { - const variableIsAtSentenceStart = template.startsWith(`{{${name}}}`) - || template.startsWith(`{{${name},`); - const shouldCapitalize = typeof value === 'string' && variableIsAtSentenceStart; - return [name, shouldCapitalize ? capitalizeFirstLetter(value) : value]; - }), - ); - - return i18n.t(key, { - ...casedValues, - interpolation: { escapeValue: false, ...interpolation }, - }); -}; From 6c9e94a0365400b171e579b76ea98e4aa596fbe4 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:41:56 +0000 Subject: [PATCH 35/57] feat: global uppercase handler for custom term --- i18n/en.pot | 8 ++--- .../WidgetEnrollment.component.tsx | 2 +- .../WidgetStagesAndEvents.component.tsx | 10 +++++- src/i18n/setupFormatters.ts | 34 +++++++++++++++++++ src/index.tsx | 1 + 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/i18n/setupFormatters.ts diff --git a/i18n/en.pot b/i18n/en.pot index 0f3c882188..b3a242e6cf 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T19:00:19.764Z\n" -"PO-Revision-Date: 2026-09-02T19:00:19.764Z\n" +"POT-Creation-Date: 2026-09-03T09:41:58.884Z\n" +"PO-Revision-Date: 2026-09-03T09:41:58.884Z\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 "{{programStagesLabel}} and {{eventsLabel}}" +msgstr "{{programStagesLabel}} and {{eventsLabel}}" 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/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 78830c7d04..49abc929b9 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -12,6 +12,7 @@ import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; +import { customTerms } from '../../utils/customTerms'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; @@ -24,7 +25,6 @@ import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName import { Date } from './Date'; import { Actions } from './Actions'; import { MiniMap } from './MiniMap'; -import { customTerms } from '../../utils/customTerms'; const styles = { enrollment: { 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 13a96518fc..95c5e7b5cf 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -6,6 +6,7 @@ import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { Stages } from './Stages'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; +import { useTermLabel } from '../../metaData'; import type { Props } from './stagesAndEvents.types'; const styles = { @@ -35,6 +36,8 @@ const WidgetStagesAndEventsPlain = ({ multipleStages, showWidgetBadge, } = useEnrollmentAccessContext(); + const programStagesLabel = useTermLabel('programStage', { programId, plural: true }); + const eventsLabel = useTermLabel('event', { programId, plural: true }); return (
- {i18n.t('Program stages and events')} + + {i18n.t('{{programStagesLabel}} and {{eventsLabel}}', { + programStagesLabel, + eventsLabel, + })} + {showWidgetBadge && (
) => { + const name = template.trimStart().match(/^\{\{(\w+)/)?.[1]; + if (!name || !CUSTOM_TERM_VARS.has(name) || typeof data[name] !== 'string') return data; + return { ...data, [name]: capitalizeFirstLetter(data[name] as string) }; +}; + +type Interpolator = { + interpolate: (str: string, data: Record, lng: string, opts: unknown) => string; + escapeValue: boolean; + options: { interpolation: { escapeValue: boolean } }; +}; +const interpolator = (i18n as unknown as { services?: { interpolator?: Interpolator } }).services?.interpolator; +if (interpolator) { + interpolator.options.interpolation.escapeValue = false; + interpolator.escapeValue = false; + const original = interpolator.interpolate.bind(interpolator); + interpolator.interpolate = (str, data, lng, opts) => + original(str, capFirstCustomTerm(str, data ?? {}), lng, opts); +} diff --git a/src/index.tsx b/src/index.tsx index c25ff0c33c..d0f190a285 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; import 'regenerator-runtime'; // To fix the 'regeneratorRuntime is not defined' error comming from react-leaflet-search-unpolyfilled import 'capture-core-utils/extensions/asyncForEachArray'; import 'capture-core-utils/extensions/arrayToHashMap'; +import './i18n/setupFormatters'; import './locales'; //eslint-disable-line import { AppStart } from './components/AppStart'; From 0991fe5506a15c85ed24e1d610735e777c31e4bb Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:41:41 +0000 Subject: [PATCH 36/57] fix: revert changes that belongs to child pr --- .../WidgetEnrollmentNote/index.js | 2 +- i18n/en.pot | 64 +++++++++-------- .../Filters/FiltersRows.component.tsx | 2 +- .../NewEventWorkspace.component.tsx | 2 +- .../ProgramStageSelector.container.tsx | 2 +- .../TopBar/TopBar.component.tsx | 2 +- .../EnrollmentEditEvent/TopBar.container.tsx | 2 +- .../NotesSection/NotesSection.component.tsx | 4 +- .../RelationshipsSection.component.tsx | 4 +- .../useTeiDisplayName.ts | 4 +- .../Relationships/Relationships.component.tsx | 6 +- .../WidgetBreakingTheGlass.component.tsx | 12 ++-- .../Status/Status.component.tsx | 4 +- .../WidgetEnrollment.component.tsx | 12 +--- .../constants/status.const.ts | 2 +- .../WidgetEnrollmentEventNew.container.tsx | 2 +- .../DataEntry/editEventDataEntry.actions.ts | 2 +- .../epics/editEventDataEntry.epics.ts | 2 +- .../viewEventDataEntry.actions.ts | 2 +- .../ScheduleDate/ScheduleDate.component.tsx | 3 +- .../WidgetEventSchedule.container.tsx | 2 +- .../WidgetProfile/hooks/useTeiDisplayName.ts | 6 +- .../hooks/useStageLabels.ts | 9 +-- .../utils/getDataEntryDetails.ts | 68 +++++++++---------- .../trackedEntityInstances/getDisplayName.ts | 4 +- 25 files changed, 115 insertions(+), 109 deletions(-) diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js index e96b83be28..67f9b5df5f 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollmentNote/index.js @@ -3,7 +3,7 @@ import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; const timeStamp = Math.round((new Date()).getTime() / 1000); Then('the stages and events should be loaded', () => { - cy.contains('Program stages and events').should('exist'); + cy.contains('Stages and Events').should('exist'); }); When(/^you fill in the note: (.*)$/, (note) => { diff --git a/i18n/en.pot b/i18n/en.pot index b3a242e6cf..e0670f8611 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-03T09:41:58.884Z\n" -"PO-Revision-Date: 2026-09-03T09:41:58.884Z\n" +"POT-Creation-Date: 2026-09-04T08:41:42.551Z\n" +"PO-Revision-Date: 2026-09-04T08:41:42.552Z\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 "Program stage filters" -msgstr "Program stage filters" +msgid "Stage filters" +msgstr "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 "Program stage not found" -msgstr "Program stage not found" +msgid "Stage not found" +msgstr "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 program stage for a new event" -msgstr "Choose a program stage for a new event" +msgid "Choose a stage for a new event" +msgstr "Choose a stage for a new event" msgid "Program Stages could not be loaded" msgstr "Program Stages could not be loaded" -msgid "Program stage" -msgstr "Program stage" +msgid "Stage" +msgstr "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." @@ -1306,22 +1306,9 @@ msgstr "No one is assigned to this event" msgid "Assign" msgstr "Assign" -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." -msgstr "" -"You must provide a reason to check for enrollments in this protected " -"program." - -msgid "All activity will be logged." -msgstr "All activity will be logged." - msgid "Reason to check for enrollments" msgstr "Reason to check for enrollments" @@ -1332,6 +1319,19 @@ msgstr "" "Describe the reason you are checking for enrollments in this protected " "program" +msgid "Check for enrollments" +msgstr "Check for enrollments" + +msgid "" +"You must provide a reason to check for enrollments in this protected " +"program." +msgstr "" +"You must provide a reason to check for enrollments in this protected " +"program." + +msgid "All activity will be logged." +msgstr "All activity will be logged." + msgid "Unsaved changes" msgstr "Unsaved changes" @@ -1446,8 +1446,8 @@ msgstr "Enrollment date" msgid "Incident date" msgstr "Incident date" -msgid "{{enrollmentLabel}} widget could not be loaded. Please try again later" -msgstr "{{enrollmentLabel}} widget could not be loaded. Please try again later" +msgid "Enrollment widget could not be loaded. Please try again later" +msgstr "Enrollment widget could not be loaded. Please try again later" msgid "Follow-up" msgstr "Follow-up" @@ -1467,6 +1467,9 @@ 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." @@ -1476,8 +1479,8 @@ msgstr "Saving to {{stageName}} for {{programName}} in {{orgUnitName}}" msgid "Saving to {{stageName}} for {{programName}}" msgstr "Saving to {{stageName}} for {{programName}}" -msgid "Program or program stage is invalid" -msgstr "Program or program stage is invalid" +msgid "program or stage is invalid" +msgstr "program or stage is invalid" msgid "Notes about this enrollment" msgstr "Notes about this enrollment" @@ -1494,8 +1497,8 @@ msgstr "Error" msgid "Warning" msgstr "Warning" -msgid "Program stage not found in rules execution" -msgstr "Program stage not found in rules execution" +msgid "stage not found in rules execution" +msgstr "stage not found in rules execution" msgid "Delete event" msgstr "Delete event" @@ -1590,6 +1593,9 @@ 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" 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 fcb22ae905..97fb2d473f 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('Program stage filters').toUpperCase()}
+
{i18n.t('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 6122ca58cb..fd6f703bb5 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('Program stage not found')}
, +
{i18n.t('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 1a3442a42b..10e33b8309 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 dc2357d1a3..d5dd0a2dc1 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('Program stage')} + title={i18n.t('Stage')} isUserInteractionInProgress={isUserInteractionInProgress} /> {programStage && ( 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 aba080dd1b..bcd0546089 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,6 +12,8 @@ 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, @@ -40,7 +42,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 b9f3302044..dd3189f132 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,6 +15,8 @@ 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, @@ -51,7 +53,7 @@ class RelationshipsSectionPlain extends React.Component { return ( 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 b07e923acf..06813db015 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,6 +6,8 @@ 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; @@ -37,7 +39,7 @@ const getTetAttributes = (attributes: Array, tetAttributes: Array, trackedEntityType: string, teiId: string) => { const tetAttributes = getAttributesFromScopeId(trackedEntityType); - if (!attributes || !tetAttributes) return teiId ?? i18n.t('tracked entity instance'); + if (!attributes || !tetAttributes) return teiId ?? DEFAULT_NAME; 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 69c2596a9f..0762e0d941 100644 --- a/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx +++ b/src/core_modules/capture-core/components/Relationships/Relationships.component.tsx @@ -63,9 +63,9 @@ const styles: Readonly = (theme: any) => ({ }, }); -const getFromNames = () => ({ +const fromNames = { PROGRAM_STAGE_INSTANCE: i18n.t('This event'), -}); +}; type PlainProps = { relationships: Array; @@ -105,7 +105,7 @@ class RelationshipsPlain extends React.Component { const { onRenderConnectedEntity } = this.props; if (entity.id === this.props.currentEntityId) { - return getFromNames()[entity.type]; + return fromNames[entity.type]; } return onRenderConnectedEntity ? onRenderConnectedEntity(entity) : entity.name; 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 494b3c5ba1..79c8583d90 100644 --- a/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx +++ b/src/core_modules/capture-core/components/WidgetBreakingTheGlass/WidgetBreakingTheGlass.component.tsx @@ -23,6 +23,10 @@ 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 = ({ @@ -48,17 +52,15 @@ 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.')}
- {getTranslatedStatus()[status] ?? status} + {translatedStatus[status] ?? status} ); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 49abc929b9..29b02e6023 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -11,15 +11,13 @@ import { import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; -import { customTerms } from '../../utils/customTerms'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes, useTermLabel } from '../../metaData'; +import { dataElementTypes } from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -84,7 +82,6 @@ const WidgetEnrollmentPlain = ({ onAccessLostFromTransfer, }: PlainProps & WithStyles) => { const { programWriteAccess, showWidgetBadge } = useEnrollmentAccessContext(); - const enrollmentLabel = useTermLabel('enrollment'); const enrollmentReadOnly = readOnlyMode || !programWriteAccess; const [open, setOpenStatus] = useState(true); const { fromServerDate } = useTimeZoneConversion(); @@ -103,7 +100,7 @@ const WidgetEnrollmentPlain = ({ - {capitalizeFirstLetter(enrollmentLabel)} + {i18n.t('Enrollment')} {showWidgetBadge && (
{initError && (
- {customTerms.i18n.t( - '{{enrollmentLabel}} widget could not be loaded. Please try again later', - { enrollmentLabel }, - )} + {i18n.t('Enrollment widget could not be loaded. Please try again later')}
)} {loading && } 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 a8e0e23d69..2b0bb8939f 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 getTranslatedStatus = () => ({ +export const translatedStatus = Object.freeze({ [plainStatus.ACTIVE]: i18n.t('Active'), [plainStatus.COMPLETED]: i18n.t('Completed'), [plainStatus.CANCELLED]: i18n.t('Cancelled'), 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 5c2a625856..4e48426865 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 program stage is invalid')} + {i18n.t('program or 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 10d9a263fb..6f4b8b9243 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('Program stage not found in rules execution')); + throw Error(i18n.t('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 eee466d3b6..cd0f06fee8 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('Program stage not found in rules execution')); + throw Error(i18n.t('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 026a42ccdf..f01c953420 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 @@ -145,7 +145,7 @@ export const loadViewEventDataEntry = if (program instanceof TrackerProgram) { const stage = getStageFromEvent(eventContainer.event)?.stage; if (!stage) { - throw Error(i18n.t('Program stage not found in rules execution')); + throw Error(i18n.t('stage not found in rules execution')); } effects = getApplicableRuleEffectsForTrackerProgram({ diff --git a/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx b/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx index ef5a063a4d..e27abe562d 100644 --- a/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventSchedule/ScheduleDate/ScheduleDate.component.tsx @@ -11,7 +11,6 @@ import { } from 'capture-core/components/FormFields/New'; import { isValidDate, isValidPeriod } from 'capture-core/utils/validation/validators/form'; import { hasValue } from 'capture-core-utils/validators/form'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { systemSettingsStore } from '../../../metaDataMemoryStores'; import labelTypeClasses from './dataEntryFieldLabels.module.css'; import { InfoBox } from '../InfoBox'; @@ -134,7 +133,7 @@ const ScheduleDatePlain = ({ /> :
- {displayDueDateLabel ? capitalizeFirstLetter(displayDueDateLabel) : i18n.t('Schedule date / Due date', { + {displayDueDateLabel ?? i18n.t('Schedule date / Due date', { interpolation: { escapeValue: false }, }, )} 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 7803a97d33..2b99d00290 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 program stage is invalid')} + {i18n.t('Program or stage is invalid')}
); } 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 dec5ad533c..0b8f9ec300 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTeiDisplayName.ts @@ -2,6 +2,8 @@ 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; @@ -47,7 +49,7 @@ const deriveTeiName = ( tetAttributes: TetAttribute[], teiId?: string, ) => { - if (!attributes || !tetAttributes) return teiId ?? i18n.t('tracked entity instance'); + if (!attributes || !tetAttributes) return teiId ?? DEFAULT_NAME; const teiNameDisplayInList = getTetAttributesDisplayInList(attributes, tetAttributes as TetAttribute[]); if (teiNameDisplayInList) return teiNameDisplayInList; @@ -55,7 +57,7 @@ const deriveTeiName = ( const teiName = getTetAttributes(attributes, tetAttributes); if (teiName) return teiName; - return teiId ?? i18n.t('tracked entity instance'); + return teiId ?? DEFAULT_NAME; }; export const useTeiDisplayName = ( diff --git a/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts b/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts index 2955ca81c8..2cdbd0a2ee 100644 --- a/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts +++ b/src/core_modules/capture-core/components/WidgetRelatedStages/hooks/useStageLabels.ts @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { getUserMetadataStorageController, USER_METADATA_STORES } from '../../../storageControllers'; import { useIndexedDBQuery } from '../../../utils/reactQueryHelpers'; @@ -24,12 +23,8 @@ export const useStageLabels = (programId: string, programStageId?: string) => { ); return { - scheduledLabel: data?.displayDueDateLabel - ? capitalizeFirstLetter(data.displayDueDateLabel) - : i18n.t('Scheduled date'), - occurredLabel: data?.displayExecutionDateLabel - ? capitalizeFirstLetter(data.displayExecutionDateLabel) - : i18n.t('Report date'), + scheduledLabel: data?.displayDueDateLabel ?? i18n.t('Scheduled date'), + occurredLabel: data?.displayExecutionDateLabel ?? i18n.t('Report date'), isLoading: isInitialLoading, error, }; 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 7d5c10dbf1..892b22a4a0 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', }; -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 StatusLabels = { + 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: string) => 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: keyof typeof StatusLabels) => StatusLabels[value], + }, +}; - const dataEntryValues = Object.values(dataEntryFieldsToInclude).map((entry: any) => { +export const getDataEntryDetails = (linkedEvent: LinkedEvent, formFoundation: RenderFoundation) => { + 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 577af518e8..e70af09e5d 100644 --- a/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts +++ b/src/core_modules/capture-core/trackedEntityInstances/getDisplayName.ts @@ -2,6 +2,8 @@ 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, @@ -11,7 +13,7 @@ export function getDisplayName( const displayValues = attributes.filter(a => valueIds.some(id => id === a.id) && a.displayInReports); if (displayValues.length === 0) { - return fallbackName || i18n.t('tracked entity instance'); + return fallbackName || DEFAULT_NAME; } return displayValues.slice(0, 2) From a19e6742ea371460d9ded4c7f79416f7bfd77986 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:51:00 +0000 Subject: [PATCH 37/57] fix: revert cypress test --- cypress/e2e/ScopeSelector/ScopeSelector.js | 2 +- i18n/en.pot | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/ScopeSelector/ScopeSelector.js b/cypress/e2e/ScopeSelector/ScopeSelector.js index 90e7a8e300..b3298a75e6 100644 --- a/cypress/e2e/ScopeSelector/ScopeSelector.js +++ b/cypress/e2e/ScopeSelector/ScopeSelector.js @@ -284,7 +284,7 @@ And('you see the enrollment event Edit page but there is no org unit id in the u And('you see the enrollment event New page but there is no stage id in the url', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/#/enrollmentEventNew?enrollmentId=Aemr3Q02aqV&orgUnitId=DiszpKrYNg8&programId=ur1Edk5Oe2n&teiId=eUTmQGull6H`); - cy.contains('Choose a program stage for a new event'); + cy.contains('Choose a stage for a new event'); }); And('you see the enrollment page without org unit in the url', () => { diff --git a/i18n/en.pot b/i18n/en.pot index e0670f8611..730e6ec42c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T08:41:42.551Z\n" -"PO-Revision-Date: 2026-09-04T08:41:42.552Z\n" +"POT-Creation-Date: 2026-09-04T08:51:01.668Z\n" +"PO-Revision-Date: 2026-09-04T08:51:01.668Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." From d8cb582e41a8f9c2af3f9f4909322d5c5d0b3e05 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:05:44 +0000 Subject: [PATCH 38/57] feat: clean up --- i18n/en.pot | 12 ++++++------ .../ViewEventDataEntry.container.ts | 10 +++++++++- .../StageCreateNewButton/StageCreateNewButton.tsx | 2 +- .../Stages/Stages.component.tsx | 2 +- .../WidgetStagesAndEvents.component.tsx | 2 ++ src/i18n/setupFormatters.ts | 7 +++++++ 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 730e6ec42c..9e5be2035c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T08:51:01.668Z\n" -"PO-Revision-Date: 2026-09-04T08:51:01.668Z\n" +"POT-Creation-Date: 2026-09-04T09:05:45.649Z\n" +"PO-Revision-Date: 2026-09-04T09:05:45.649Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1754,8 +1754,8 @@ msgstr "Please enter a date" msgid "Please select a valid event" msgstr "Please select a valid event" -msgid "This program stage can only have one event" -msgstr "This program stage can only have one event" +msgid "This stage can only have one event" +msgstr "This stage can only have one event" msgid "New {{ eventName }} event" msgstr "New {{ eventName }} event" @@ -1804,8 +1804,8 @@ msgstr "{{ overdueEvents }} overdue" msgid "{{ scheduledEvents }} scheduled" msgstr "{{ scheduledEvents }} scheduled" -msgid "No program stages found in this program" -msgstr "No program stages found in this program" +msgid "No stages found in this program" +msgstr "No stages found in this program" msgid "{{programStagesLabel}} and {{eventsLabel}}" msgstr "{{programStagesLabel}} and {{eventsLabel}}" diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts index c9ecd0d685..60f11c27db 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts @@ -1,7 +1,15 @@ import { connect } from 'react-redux'; import { ViewEventDataEntryComponent } from './ViewEventDataEntry.component'; import { withLoadingIndicator } from '../../../HOC/withLoadingIndicator'; +import { withCustomLabels } from '../../../HOC/withCustomLabels'; +// Example use of withCustomLabels: injects `orgUnitLabel` and `eventLabel` as +// props, resolved against the current program's custom terminology (programId +// is supplied via mapStateToProps below). +const customLabels = { + orgUnitLabel: { key: 'orgUnit' }, + eventLabel: { key: 'event' }, +} as const; const mapStateToProps = (state: any, props: any) => { const eventDetailsSection = state.viewEventPage.eventDetailsSection || {}; @@ -20,5 +28,5 @@ const mapStateToProps = (state: any, props: any) => { const mapDispatchToProps = (): any => ({}); export const ViewEventDataEntry = connect(mapStateToProps, mapDispatchToProps)( - withLoadingIndicator()(ViewEventDataEntryComponent), + withLoadingIndicator()(withCustomLabels(customLabels)(ViewEventDataEntryComponent)), ); 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 f1bd8bea3c..891650a162 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 program stage can only have one event'), + tooltipContent: i18n.t('This 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 bca2944634..6ee363c119 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 program stages found in this program')} + {i18n.t('No 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 95c5e7b5cf..5f34f80285 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -36,6 +36,8 @@ const WidgetStagesAndEventsPlain = ({ multipleStages, showWidgetBadge, } = useEnrollmentAccessContext(); + // Example use of useTermLabel: resolves the plural program-stage and event + // labels against the current program's custom terminology. const programStagesLabel = useTermLabel('programStage', { programId, plural: true }); const eventsLabel = useTermLabel('event', { programId, plural: true }); diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts index e9e9717198..04ab52b23b 100644 --- a/src/i18n/setupFormatters.ts +++ b/src/i18n/setupFormatters.ts @@ -1,3 +1,10 @@ +/** + * Startup patch to d2-i18n's interpolator for configured custom terms: + * - skips HTML escaping so labels render as-is + * - locale-aware capitalizes the first letter of a custom-term variable + * when it is the leading token in a template + */ + import i18n from '@dhis2/d2-i18n'; import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; From cff6516cc85e0e878001e49d57fceff7a78cbbd5 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:48:59 +0000 Subject: [PATCH 39/57] feat: clean up and add comments --- i18n/en.pot | 25 +++++++---- .../DataEntryWidgetOutput.container.ts | 21 +++++++--- .../ViewEventDataEntry.component.tsx | 6 ++- .../metaData/helpers/customLabels.ts | 14 ++++--- .../capture-core/metaData/helpers/index.ts | 3 +- .../capture-core/metaData/index.ts | 3 +- .../capture-core/utils/customTerms.ts | 41 ------------------- 7 files changed, 46 insertions(+), 67 deletions(-) delete mode 100644 src/core_modules/capture-core/utils/customTerms.ts diff --git a/i18n/en.pot b/i18n/en.pot index 9e5be2035c..141637dfc0 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T09:05:45.649Z\n" -"PO-Revision-Date: 2026-09-04T09:05:45.649Z\n" +"POT-Creation-Date: 2026-09-04T09:49:00.998Z\n" +"PO-Revision-Date: 2026-09-04T09:49:00.998Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -424,11 +424,11 @@ msgstr "Some operations are still running. Please wait." msgid "Operations running" msgstr "Operations running" -msgid "No feedback for this enrollment yet" -msgstr "No feedback for this enrollment yet" +msgid "No feedback for this {{enrollmentLabel}} yet" +msgstr "No feedback for this {{enrollmentLabel}} yet" -msgid "No indicator output for this enrollment yet" -msgstr "No indicator output for this enrollment yet" +msgid "No indicator output for this {{enrollmentLabel}} yet" +msgstr "No indicator output for this {{enrollmentLabel}} yet" msgid "" "This event has unsaved changes. Leaving this page without saving will lose " @@ -718,6 +718,12 @@ msgstr "Notice" msgid "Close the notice" msgstr "Close the notice" +msgid "No feedback for this enrollment yet" +msgstr "No feedback for this enrollment yet" + +msgid "No indicator output for this enrollment yet" +msgstr "No indicator output for this enrollment yet" + msgid "Quick actions" msgstr "Quick actions" @@ -1533,8 +1539,11 @@ msgstr "Polygon captured" msgid "No polygon captured" msgstr "No polygon captured" -msgid "Event completed" -msgstr "Event completed" +msgid "{{eventLabel}} completed" +msgstr "{{eventLabel}} completed" + +msgid "Event" +msgstr "Event" msgid "Notes about this event" msgstr "Notes about this event" diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index bca310bb95..6145603434 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -1,9 +1,10 @@ +import i18n from '@dhis2/d2-i18n'; import { connect } from 'react-redux'; import React, { type ComponentType } from 'react'; -import i18n from '@dhis2/d2-i18n'; import { DataEntryWidgetOutputComponent } from './DataEntryWidgetOutput.component'; import { getDataEntryKey } from '../DataEntry/common/getDataEntryKey'; import { makeProgramRulesSelector } from './DataEntryWidgetOutput.selectors'; +import { getTermLabel } from '../../metaData'; type OwnProps = { dataEntryId: string; @@ -17,18 +18,26 @@ const makeMapStateToProps = () => { const { dataEntries } = state; const ready = !!dataEntries[dataEntryId]; const dataEntryKey = ready ? getDataEntryKey(dataEntryId, state.dataEntries[dataEntryId].itemId) : null; + // Example use of getTermLabel: programId comes from the container's selectedScopeId prop. + const enrollmentLabel = getTermLabel('enrollment', { programId: selectedScopeId }); return { ready, dataEntryKey, programRules: programRulesSelector(state, { dataEntryId, selectedScopeId }), - feedbackEmptyText: i18n.t('No feedback for this enrollment yet'), - indicatorEmptyText: i18n.t('No indicator output for this enrollment yet'), + feedbackEmptyText: i18n.t( + 'No feedback for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), + indicatorEmptyText: i18n.t( + 'No indicator output for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), }; }; }; export const DataEntryWidgetOutput: ComponentType = - connect(makeMapStateToProps, () => ({}))( - (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), - ); + connect(makeMapStateToProps, () => ({}))( + (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), + ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx index 42edee90ce..88a7e0efaa 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx @@ -147,7 +147,8 @@ const buildOrgUnitSettingsFn = () => { const orgUnitSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Organisation unit'), + // Example use of withCustomLabels: orgUnitLabel is injected as a prop by the HOC, resolved against the current program's custom terminology. + label: props.orgUnitLabel ?? i18n.t('Organisation unit'), valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), getPropName: () => 'orgUnit', @@ -223,7 +224,8 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Event completed'), + // Example use of withCustomLabels: eventLabel is injected as a prop by the HOC, resolved against the current program's custom terminology. + label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel ?? i18n.t('Event') }), id: dataElement.id, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index 6142bf5884..a393bfb904 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -55,7 +55,7 @@ const LABELS = asLabels({ export type CustomLabelKey = keyof typeof LABELS; export type CustomLabels = Record; -export type LabelOptions = { plural?: boolean }; +type LabelOptions = { plural?: boolean }; const ALL_FIELD_NAMES = Object.values(LABELS).flatMap( ({ field, pluralField }) => (pluralField ? [field, pluralField] : [field]), @@ -70,7 +70,7 @@ export const extractCustomLabels = (cached: Record): CustomLabe type LabelSource = CustomLabels | undefined | null; -export const resolveLabel = ( +const resolveLabel = ( sources: LabelSource | Array, key: CustomLabelKey, { plural = false }: LabelOptions = {}, @@ -82,12 +82,14 @@ export const resolveLabel = ( return list.find(source => source?.[target])?.[target]; }; -type TermLabelOptions = LabelOptions & { stageId?: string | null; programId?: string | null }; +type BaseTermOptions = LabelOptions & { stageId?: string | null }; +type GetTermLabelOptions = BaseTermOptions & { programId: string }; +type UseTermLabelOptions = BaseTermOptions & { programId?: string | null }; const resolveTerm = ( programId: string | null | undefined, key: CustomLabelKey, - { stageId, plural = false }: TermLabelOptions, + { stageId, plural = false }: BaseTermOptions, ): string => { const program = programId ? programCollection.get(programId) : undefined; const stage = program && stageId ? program.getStage(stageId) : undefined; @@ -99,12 +101,12 @@ const resolveTerm = ( export const getTermLabel = ( key: CustomLabelKey, - options: TermLabelOptions & { programId: string }, + options: GetTermLabelOptions, ): string => resolveTerm(options.programId, key, options); export const useTermLabel = ( key: CustomLabelKey, - options: TermLabelOptions = {}, + options: UseTermLabelOptions = {}, ): string => { const { programId, stageId, plural } = options; const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index b4158ea1a9..bd4c8616e1 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -19,8 +19,7 @@ export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { extractCustomLabels, - resolveLabel, getTermLabel, useTermLabel, } from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; +export type { CustomLabelKey, CustomLabels } from './customLabels'; diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 1e8e7461b6..2af57c5081 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,8 +41,7 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, extractCustomLabels, - resolveLabel, getTermLabel, useTermLabel, } from './helpers'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; +export type { CustomLabelKey, CustomLabels } from './helpers'; diff --git a/src/core_modules/capture-core/utils/customTerms.ts b/src/core_modules/capture-core/utils/customTerms.ts deleted file mode 100644 index 29600a0028..0000000000 --- a/src/core_modules/capture-core/utils/customTerms.ts +++ /dev/null @@ -1,41 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; - -type I18nInternal = { - getResource: (lang: string, ns: string, key: string) => string | undefined; - language: string; -}; -const internal = i18n as unknown as I18nInternal; - -const getTranslatedTemplate = (key: string): string => - internal.getResource(internal.language, 'default', key) - ?? internal.getResource('en', 'default', key) - ?? key; - -type Options = { - interpolation?: Record; - [key: string]: unknown; -}; - -export const customTerms = { - i18n: { - t: (key: string, options: Options = {}): string => { - const { interpolation, ...values } = options; - const template = getTranslatedTemplate(key).trimStart(); - - const casedValues = Object.fromEntries( - Object.entries(values).map(([name, value]) => { - const variableIsAtSentenceStart = template.startsWith(`{{${name}}}`) - || template.startsWith(`{{${name},`); - const shouldCapitalize = typeof value === 'string' && variableIsAtSentenceStart; - return [name, shouldCapitalize ? capitalizeFirstLetter(value) : value]; - }), - ); - - return i18n.t(key, { - ...casedValues, - interpolation: { escapeValue: false, ...interpolation }, - }); - }, - }, -}; From 3f3f263c5e486b3b76df1796a74cfbea71059ed3 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:51:13 +0000 Subject: [PATCH 40/57] fix: (sonar qube) optimize regex usage for extracting leading custom term variable --- i18n/en.pot | 4 ++-- src/i18n/setupFormatters.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 141637dfc0..149d3edb4f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T09:49:00.998Z\n" -"PO-Revision-Date: 2026-09-04T09:49:00.998Z\n" +"POT-Creation-Date: 2026-09-04T09:51:14.829Z\n" +"PO-Revision-Date: 2026-09-04T09:51:14.830Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts index 04ab52b23b..2b559aec2e 100644 --- a/src/i18n/setupFormatters.ts +++ b/src/i18n/setupFormatters.ts @@ -20,8 +20,10 @@ const CUSTOM_TERM_VARS = new Set([ 'trackedEntityLabel', 'trackedEntityTypesLabel', ]); +const LEADING_VAR_REGEX = /^\{\{(\w+)/; + const capFirstCustomTerm = (template: string, data: Record) => { - const name = template.trimStart().match(/^\{\{(\w+)/)?.[1]; + const name = LEADING_VAR_REGEX.exec(template.trimStart())?.[1]; if (!name || !CUSTOM_TERM_VARS.has(name) || typeof data[name] !== 'string') return data; return { ...data, [name]: capitalizeFirstLetter(data[name] as string) }; }; From 7ab88ca15db8bed9f3631412720160a78486232f Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:59:33 +0000 Subject: [PATCH 41/57] fix: clean up --- i18n/en.pot | 4 +- .../metaData/helpers/customLabels.ts | 4 +- src/i18n/setupFormatters.ts | 51 +++++++++++-------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 149d3edb4f..28e98969c2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T09:51:14.829Z\n" -"PO-Revision-Date: 2026-09-04T09:51:14.830Z\n" +"POT-Creation-Date: 2026-09-04T10:59:35.118Z\n" +"PO-Revision-Date: 2026-09-04T10:59:35.118Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index a393bfb904..d109b21205 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -109,8 +109,8 @@ export const useTermLabel = ( options: UseTermLabelOptions = {}, ): string => { const { programId, stageId, plural } = options; - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const id = programId ?? currentProgramId; + const id = useSelector(({ currentSelections }: any) => + programId ?? currentSelections.programId); return useMemo( () => resolveTerm(id, key, { stageId, plural }), [id, key, stageId, plural], diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts index 2b559aec2e..9cd86648f5 100644 --- a/src/i18n/setupFormatters.ts +++ b/src/i18n/setupFormatters.ts @@ -1,8 +1,11 @@ /** - * Startup patch to d2-i18n's interpolator for configured custom terms: - * - skips HTML escaping so labels render as-is - * - locale-aware capitalizes the first letter of a custom-term variable - * when it is the leading token in a template + * Patches d2-i18n's interpolator for custom terminology. + * + * 1. If the template interpolates any variable listed in CUSTOM_TERM_VARS, + * HTML escaping is disabled for that call. + * All other translations keep default HTML escaping (XSS safety). + * 2. If a custom-term variable is the leading token in a template, its value + * is capitalized (locale-aware). */ import i18n from '@dhis2/d2-i18n'; @@ -20,24 +23,28 @@ const CUSTOM_TERM_VARS = new Set([ 'trackedEntityLabel', 'trackedEntityTypesLabel', ]); -const LEADING_VAR_REGEX = /^\{\{(\w+)/; +const patchInterpolator = () => { + const interpolator = (i18n as any).services?.interpolator; + if (!interpolator) return; -const capFirstCustomTerm = (template: string, data: Record) => { - const name = LEADING_VAR_REGEX.exec(template.trimStart())?.[1]; - if (!name || !CUSTOM_TERM_VARS.has(name) || typeof data[name] !== 'string') return data; - return { ...data, [name]: capitalizeFirstLetter(data[name] as string) }; -}; + const original = interpolator.interpolate.bind(interpolator); + interpolator.interpolate = (template: string, data: Record, lng: string, opts: any) => { + const usedVars = [...template.matchAll(/\{\{\s*(\w+)/g)].map(m => m[1]); + const hasCustomTerm = usedVars.some(name => CUSTOM_TERM_VARS.has(name)); + if (!hasCustomTerm) return original(template, data, lng, opts); + + // Capitalize the leading custom-term variable if the template starts with one. + const leading = /^\{\{\s*(\w+)/.exec(template.trimStart())?.[1]; + const shouldCapitalize = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string'; + const preparedData = shouldCapitalize + ? { ...data, [leading]: capitalizeFirstLetter(data[leading] as string) } + : (data ?? {}); -type Interpolator = { - interpolate: (str: string, data: Record, lng: string, opts: unknown) => string; - escapeValue: boolean; - options: { interpolation: { escapeValue: boolean } }; + // Skip HTML escaping so custom terms like "R&D" render literally. + const preparedOpts = { ...opts, interpolation: { ...opts?.interpolation, escapeValue: false } }; + + return original(template, preparedData, lng, preparedOpts); + }; }; -const interpolator = (i18n as unknown as { services?: { interpolator?: Interpolator } }).services?.interpolator; -if (interpolator) { - interpolator.options.interpolation.escapeValue = false; - interpolator.escapeValue = false; - const original = interpolator.interpolate.bind(interpolator); - interpolator.interpolate = (str, data, lng, opts) => - original(str, capFirstCustomTerm(str, data ?? {}), lng, opts); -} + +patchInterpolator(); From 465c7319bfcdaaf6155ae133723a5c2ced899e75 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:09:35 +0000 Subject: [PATCH 42/57] fix: (review) devin comments --- .devcontainer/docker-compose.yml | 2 +- i18n/en.pot | 4 ++-- .../capture-core/HOC/withCustomLabels.tsx | 8 +++++--- src/i18n/setupFormatters.ts | 12 +++++++----- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f087d952ea..6b55e3d6d9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -13,4 +13,4 @@ services: volumes: node_modules: yarn_cache: - cypress_cache: + cypress_cache: \ No newline at end of file diff --git a/i18n/en.pot b/i18n/en.pot index 28e98969c2..99000cc9d6 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T10:59:35.118Z\n" -"PO-Revision-Date: 2026-09-04T10:59:35.118Z\n" +"POT-Creation-Date: 2026-09-04T12:09:36.193Z\n" +"PO-Revision-Date: 2026-09-04T12:09:36.193Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx index d7ebf4d698..27482e7e9c 100644 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -13,15 +13,17 @@ type LabelSpecs = Record; type InjectedLabels = { [K in keyof S]: string }; export const withCustomLabels = - (specs: S) => -

>(WrappedComponent: React.ComponentType

>) => + (specs: S) => { + const entries = Object.entries(specs); + return

>(WrappedComponent: React.ComponentType

>) => (props: P & { programId?: string; stageId?: string }) => { const { programId, stageId } = props; const labels = Object.fromEntries( - Object.entries(specs).map(([propName, { key, plural }]) => [ + entries.map(([propName, { key, plural }]) => [ propName, capitalizeFirstLetter(useTermLabel(key, { programId, stageId, plural })), ]), ) as InjectedLabels; return React.createElement(WrappedComponent, { ...props, ...labels }); }; + }; diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts index 9cd86648f5..cfdde0f9c4 100644 --- a/src/i18n/setupFormatters.ts +++ b/src/i18n/setupFormatters.ts @@ -33,17 +33,19 @@ const patchInterpolator = () => { const hasCustomTerm = usedVars.some(name => CUSTOM_TERM_VARS.has(name)); if (!hasCustomTerm) return original(template, data, lng, opts); - // Capitalize the leading custom-term variable if the template starts with one. const leading = /^\{\{\s*(\w+)/.exec(template.trimStart())?.[1]; const shouldCapitalize = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string'; const preparedData = shouldCapitalize ? { ...data, [leading]: capitalizeFirstLetter(data[leading] as string) } : (data ?? {}); - // Skip HTML escaping so custom terms like "R&D" render literally. - const preparedOpts = { ...opts, interpolation: { ...opts?.interpolation, escapeValue: false } }; - - return original(template, preparedData, lng, preparedOpts); + const previousEscapeValue = interpolator.escapeValue; + interpolator.escapeValue = false; + try { + return original(template, preparedData, lng, opts); + } finally { + interpolator.escapeValue = previousEscapeValue; + } }; }; From 190c679f31956f7115f8de0c729da4216d525408 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:31:50 +0000 Subject: [PATCH 43/57] fix: clean up --- .devcontainer/docker-compose.yml | 2 +- i18n/en.pot | 4 ++-- .../DataEntryWidgetOutput.container.ts | 2 +- .../factory/programStage/ProgramStageFactory.ts | 12 ++---------- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6b55e3d6d9..f087d952ea 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -13,4 +13,4 @@ services: volumes: node_modules: yarn_cache: - cypress_cache: \ No newline at end of file + cypress_cache: diff --git a/i18n/en.pot b/i18n/en.pot index 99000cc9d6..43029373c3 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T12:09:36.193Z\n" -"PO-Revision-Date: 2026-09-04T12:09:36.193Z\n" +"POT-Creation-Date: 2026-09-04T12:31:52.107Z\n" +"PO-Revision-Date: 2026-09-04T12:31:52.107Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index 6145603434..1b77d8cf19 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -1,6 +1,6 @@ -import i18n from '@dhis2/d2-i18n'; import { connect } from 'react-redux'; import React, { type ComponentType } from 'react'; +import i18n from '@dhis2/d2-i18n'; import { DataEntryWidgetOutputComponent } from './DataEntryWidgetOutput.component'; import { getDataEntryKey } from '../DataEntry/common/getDataEntryKey'; import { makeProgramRulesSelector } from './DataEntryWidgetOutput.selectors'; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts index 5c56c02152..112aa41d14 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/programs/factory/programStage/ProgramStageFactory.ts @@ -239,16 +239,8 @@ export class ProgramStageFactory { _form.description = cachedProgramStage.description; _form.featureType = ProgramStageFactory._getFeatureType(cachedProgramStage); _form.access = cachedProgramStage.access; - const executionLabel = cachedProgramStage.displayExecutionDateLabel; - const dueDateLabel = cachedProgramStage.displayDueDateLabel; - _form.addLabel({ - id: 'occurredAt', - label: executionLabel ? capitalizeFirstLetter(executionLabel) : 'Report date', - }); - _form.addLabel({ - id: 'scheduledAt', - label: dueDateLabel ? capitalizeFirstLetter(dueDateLabel) : 'Scheduled date', - }); + _form.addLabel({ id: 'occurredAt', label: cachedProgramStage.displayExecutionDateLabel || 'Report date' }); + _form.addLabel({ id: 'scheduledAt', label: cachedProgramStage.displayDueDateLabel || 'Scheduled date' }); _form.validationStrategy = cachedProgramStage.validationStrategy && camelCaseUppercaseString(cachedProgramStage.validationStrategy); From 736bcbed218dae9c8465ab145443b1bf21fd4f32 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:33:58 +0000 Subject: [PATCH 44/57] fix: refine type definitions in withCustomLabels HOC --- i18n/en.pot | 4 ++-- src/core_modules/capture-core/HOC/withCustomLabels.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 43029373c3..682ad95552 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T12:31:52.107Z\n" -"PO-Revision-Date: 2026-09-04T12:31:52.107Z\n" +"POT-Creation-Date: 2026-09-04T13:34:00.324Z\n" +"PO-Revision-Date: 2026-09-04T13:34:00.324Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx index 27482e7e9c..5ab981abcb 100644 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -15,8 +15,8 @@ type InjectedLabels = { [K in keyof S]: string }; export const withCustomLabels = (specs: S) => { const entries = Object.entries(specs); - return

>(WrappedComponent: React.ComponentType

>) => - (props: P & { programId?: string; stageId?: string }) => { + return

>(WrappedComponent: React.ComponentType

) => + (props: Omit> & { programId?: string; stageId?: string }) => { const { programId, stageId } = props; const labels = Object.fromEntries( entries.map(([propName, { key, plural }]) => [ @@ -24,6 +24,7 @@ export const withCustomLabels = capitalizeFirstLetter(useTermLabel(key, { programId, stageId, plural })), ]), ) as InjectedLabels; - return React.createElement(WrappedComponent, { ...props, ...labels }); + const Component = WrappedComponent as React.ComponentType>; + return React.createElement(Component, { ...props, ...labels }); }; }; From 2644b87d573951ad1811c252f835250e3e3e55a1 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:10:54 +0000 Subject: [PATCH 45/57] feat: add plural terminology for notes, relationships, and attributes --- i18n/en.pot | 13 +++++++++++-- .../components/WidgetEnrollment/hooks/useProgram.ts | 3 ++- .../capture-core/metaData/helpers/customLabels.ts | 6 ++++++ .../programs/quickStoreOperations/storePrograms.ts | 3 +++ .../quickStoreOperations/types/apiPrograms.types.ts | 3 +++ .../storageControllers/types/cache.types.ts | 3 +++ 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 682ad95552..31f762bfb8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T13:34:00.324Z\n" -"PO-Revision-Date: 2026-09-04T13:34:00.324Z\n" +"POT-Creation-Date: 2026-09-05T15:10:55.856Z\n" +"PO-Revision-Date: 2026-09-05T15:10:55.856Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -2269,12 +2269,21 @@ msgstr "program stages" msgid "note" msgstr "note" +msgid "notes" +msgstr "notes" + msgid "relationship" msgstr "relationship" +msgid "relationships" +msgstr "relationships" + msgid "attribute" msgstr "attribute" +msgid "attributes" +msgstr "attributes" + msgid "organisation unit" msgstr "organisation unit" 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 b76d2e87ce..bdf71d067a 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -18,7 +18,8 @@ const baseFields = [ ]; const pluralFields = [ - 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel', + 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel,' + + 'displayNotesLabel,displayRelationshipsLabel,displayTrackedEntityAttributesLabel', ]; export const useProgram = (programId: string) => { diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index d109b21205..76da8e024b 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -33,15 +33,21 @@ const LABELS = asLabels({ }, note: { field: 'displayNoteLabel', + pluralField: 'displayNotesLabel', singular: () => i18n.t('note'), + plural: () => i18n.t('notes'), }, relationship: { field: 'displayRelationshipLabel', + pluralField: 'displayRelationshipsLabel', singular: () => i18n.t('relationship'), + plural: () => i18n.t('relationships'), }, attribute: { field: 'displayTrackedEntityAttributeLabel', + pluralField: 'displayTrackedEntityAttributesLabel', singular: () => i18n.t('attribute'), + plural: () => i18n.t('attributes'), }, orgUnit: { field: 'displayOrgUnitLabel', 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 7d972501b6..8e226e4880 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -172,6 +172,9 @@ const pluralProgramFields = [ 'displayEnrollmentsLabel', 'displayProgramStagesLabel', 'displayEventsLabel', + 'displayNotesLabel', + 'displayRelationshipsLabel', + 'displayTrackedEntityAttributesLabel', ]; const buildFieldsParam = (includePluralLabels: boolean): string => { 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 6809b0bbb7c32645f22ac44e7eb8889e4094522c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:52:15 +0000 Subject: [PATCH 46/57] fix: simplify useProgram hook by removing unused fields and logic --- i18n/en.pot | 4 ++-- .../components/WidgetEnrollment/hooks/useProgram.ts | 13 ++----------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 682ad95552..89aa341ec8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T13:34:00.324Z\n" -"PO-Revision-Date: 2026-09-04T13:34:00.324Z\n" +"POT-Creation-Date: 2026-09-05T18:52:16.928Z\n" +"PO-Revision-Date: 2026-09-05T18:52:16.928Z\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 b76d2e87ce..d17cccdf86 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -1,13 +1,12 @@ 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 = [ +const fields = [ 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + 'displayEnrollmentLabel,displayFollowUpLabel,displayOrgUnitLabel,' + 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + @@ -17,21 +16,13 @@ const baseFields = [ 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', ]; -const pluralFields = [ - 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel', -]; - export const useProgram = (programId: string) => { const { error, loading, data } = useDataQuery( useMemo( () => ({ program: { resource: `programs/${programId}`, - params: { - fields: featureAvailable(FEATURES.customTerminologyPlurals) - ? [...baseFields, ...pluralFields] - : baseFields, - }, + params: { fields }, }, }), [programId], From dd7848dcf08807f0889bd0ad21ecc171b868f094 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:57:10 +0000 Subject: [PATCH 47/57] Revert "fix: simplify useProgram hook by removing unused fields and logic" This reverts commit 6809b0bbb7c32645f22ac44e7eb8889e4094522c. --- i18n/en.pot | 4 ++-- .../components/WidgetEnrollment/hooks/useProgram.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 89aa341ec8..682ad95552 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-05T18:52:16.928Z\n" -"PO-Revision-Date: 2026-09-05T18:52:16.928Z\n" +"POT-Creation-Date: 2026-09-04T13:34:00.324Z\n" +"PO-Revision-Date: 2026-09-04T13:34:00.324Z\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 d17cccdf86..b76d2e87ce 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -1,12 +1,13 @@ 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 fields = [ +const baseFields = [ 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + 'displayEnrollmentLabel,displayFollowUpLabel,displayOrgUnitLabel,' + 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + @@ -16,13 +17,21 @@ const fields = [ 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', ]; +const pluralFields = [ + 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel', +]; + export const useProgram = (programId: string) => { const { error, loading, data } = useDataQuery( useMemo( () => ({ program: { resource: `programs/${programId}`, - params: { fields }, + params: { + fields: featureAvailable(FEATURES.customTerminologyPlurals) + ? [...baseFields, ...pluralFields] + : baseFields, + }, }, }), [programId], From 72c5033c427c03d8b0d834a88b054d812a54e083 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:56:20 +0000 Subject: [PATCH 48/57] feat: add getLabelFromProgram utility and update WidgetEnrollment for self containment --- i18n/en.pot | 4 ++-- .../WidgetEnrollment/WidgetEnrollment.component.tsx | 9 +++++++-- .../capture-core/metaData/helpers/customLabels.ts | 13 +++++++++++++ .../capture-core/metaData/helpers/index.ts | 1 + src/core_modules/capture-core/metaData/index.ts | 1 + 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 682ad95552..9ce670cff9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T13:34:00.324Z\n" -"PO-Revision-Date: 2026-09-04T13:34:00.324Z\n" +"POT-Creation-Date: 2026-09-05T19:56:21.909Z\n" +"PO-Revision-Date: 2026-09-05T19:56:21.909Z\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/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 29b02e6023..773844abe8 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -11,13 +11,14 @@ import { import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes } from '../../metaData'; +import { dataElementTypes, getTermLabelFromProgram } from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -94,13 +95,17 @@ const WidgetEnrollmentPlain = ({ const orgUnitClientValue = { id: enrollment?.orgUnit, name: orgUnitName, ancestors }; const ownerOrgUnitClientValue = { id: ownerOrgUnit?.id, name: ownerOrgUnitName, ancestors: ownerAncestors }; + // Example use of getTermLabelFromProgram: resolves the "enrollment" term against the + // widget's own fetched program. Keeps the widget self-contained (no dependency on + // the global programCollection memory store or Capture Redux state). + const enrollmentLabel = capitalizeFirstLetter(getTermLabelFromProgram(program, 'enrollment')); return (

- {i18n.t('Enrollment')} + {enrollmentLabel} {showWidgetBadge && (
| null | undefined, + key: CustomLabelKey, + { plural = false }: LabelOptions = {}, +): string => { + const { field, pluralField, singular } = LABELS[key]; + const target = plural ? pluralField : field; + const value = target ? program?.[target] : undefined; + if (typeof value === 'string') return value; + if (plural) return LABELS[key].plural?.() ?? singular(); + return singular(); +}; + export const getTermLabel = ( key: CustomLabelKey, options: GetTermLabelOptions, diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index bd4c8616e1..fdf6a81de4 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -19,6 +19,7 @@ export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { extractCustomLabels, + getTermLabelFromProgram, getTermLabel, useTermLabel, } from './customLabels'; diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 2af57c5081..412788a253 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,6 +41,7 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, extractCustomLabels, + getTermLabelFromProgram, getTermLabel, useTermLabel, } from './helpers'; From 791f4c73d5c3a667332978c77220245e54bf4a11 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:47:50 +0000 Subject: [PATCH 49/57] fix: refine GetTermLabelFromProgramOptions type definition and update getTermLabelFromProgram parameters --- i18n/en.pot | 4 ++-- .../capture-core/metaData/helpers/customLabels.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9ce670cff9..6f29e54375 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-05T19:56:21.909Z\n" -"PO-Revision-Date: 2026-09-05T19:56:21.909Z\n" +"POT-Creation-Date: 2026-09-06T05:47:51.689Z\n" +"PO-Revision-Date: 2026-09-06T05:47:51.689Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index 6e5c5c48b0..c04eac8485 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -85,6 +85,7 @@ const resolveLabel = ( type BaseTermOptions = LabelOptions & { stageId?: string | null }; type GetTermLabelOptions = BaseTermOptions & { programId: string }; type UseTermLabelOptions = BaseTermOptions & { programId?: string | null }; +type GetTermLabelFromProgramOptions = LabelOptions & { program: Record | null | undefined; }; const resolveTerm = ( programId: string | null | undefined, @@ -100,9 +101,8 @@ const resolveTerm = ( }; export const getTermLabelFromProgram = ( - program: Record | null | undefined, key: CustomLabelKey, - { plural = false }: LabelOptions = {}, + { program, plural = false }: GetTermLabelFromProgramOptions, ): string => { const { field, pluralField, singular } = LABELS[key]; const target = plural ? pluralField : field; From c9d572bdb1859df050ecd2c7dbde910af69dad0d Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:00:45 +0000 Subject: [PATCH 50/57] fix: update enrollment label rendering example --- i18n/en.pot | 7 +++++-- .../WidgetEnrollment/WidgetEnrollment.component.tsx | 5 ++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6f29e54375..c6326cd7e3 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T05:47:51.689Z\n" -"PO-Revision-Date: 2026-09-06T05:47:51.689Z\n" +"POT-Creation-Date: 2026-09-06T06:00:46.335Z\n" +"PO-Revision-Date: 2026-09-06T06:00:46.335Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1452,6 +1452,9 @@ msgstr "Enrollment date" msgid "Incident date" msgstr "Incident date" +msgid "{{enrollmentLabel}}" +msgstr "{{enrollmentLabel}}" + msgid "Enrollment widget could not be loaded. Please try again later" msgstr "Enrollment widget could not be loaded. Please try again later" diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 773844abe8..e0b3f4e95d 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -11,7 +11,6 @@ import { import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; @@ -98,14 +97,14 @@ const WidgetEnrollmentPlain = ({ // Example use of getTermLabelFromProgram: resolves the "enrollment" term against the // widget's own fetched program. Keeps the widget self-contained (no dependency on // the global programCollection memory store or Capture Redux state). - const enrollmentLabel = capitalizeFirstLetter(getTermLabelFromProgram(program, 'enrollment')); + const enrollmentLabel = getTermLabelFromProgram('enrollment', { program }); return (
- {enrollmentLabel} + {i18n.t('{{enrollmentLabel}}', { enrollmentLabel })} {showWidgetBadge && (
Date: Sun, 6 Sep 2026 09:52:54 +0000 Subject: [PATCH 51/57] feat: refine terminology support by updating label handling and introducing LabelKeys --- i18n/en.pot | 7 +- .../capture-core/HOC/withCustomLabels.tsx | 31 ++--- .../DataEntryWidgetOutput.container.ts | 6 +- .../WidgetEnrollment.component.tsx | 13 +- .../ViewEventDataEntry.component.tsx | 4 +- .../ViewEventDataEntry.container.ts | 10 +- .../WidgetStagesAndEvents.component.tsx | 12 +- .../metaData/helpers/customLabels.ts | 121 +++++++++++------- .../capture-core/metaData/helpers/index.ts | 8 +- .../capture-core/metaData/index.ts | 7 +- 10 files changed, 116 insertions(+), 103 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c6326cd7e3..efffdf6370 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T06:00:46.335Z\n" -"PO-Revision-Date: 2026-09-06T06:00:46.335Z\n" +"POT-Creation-Date: 2026-09-06T09:52:56.280Z\n" +"PO-Revision-Date: 2026-09-06T09:52:56.281Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1458,9 +1458,6 @@ msgstr "{{enrollmentLabel}}" msgid "Enrollment widget could not be loaded. Please try again later" msgstr "Enrollment widget could not be loaded. Please try again later" -msgid "Follow-up" -msgstr "Follow-up" - msgid "Started at{{escape}}" msgstr "Started at{{escape}}" diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx index 5ab981abcb..32f3c40398 100644 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -1,30 +1,17 @@ import * as React from 'react'; import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { useTermLabel } from '../metaData'; -import type { CustomLabelKey } from '../metaData/helpers/customLabels'; - -type LabelSpec = { - key: CustomLabelKey; - plural?: boolean; -}; - -type LabelSpecs = Record; - -type InjectedLabels = { [K in keyof S]: string }; +import type { TermRequest } from '../metaData/helpers/customLabels'; export const withCustomLabels = - (specs: S) => { - const entries = Object.entries(specs); - return

>(WrappedComponent: React.ComponentType

) => - (props: Omit> & { programId?: string; stageId?: string }) => { + (requests: ReadonlyArray) => +

>(WrappedComponent: React.ComponentType

) => + (props: P & { programId?: string; stageId?: string }) => { const { programId, stageId } = props; - const labels = Object.fromEntries( - entries.map(([propName, { key, plural }]) => [ - propName, - capitalizeFirstLetter(useTermLabel(key, { programId, stageId, plural })), - ]), - ) as InjectedLabels; + const labels = useTermLabel(requests, { programId, stageId }); + const capitalized = Object.fromEntries( + Object.entries(labels).map(([key, value]) => [key, capitalizeFirstLetter(value)]), + ); const Component = WrappedComponent as React.ComponentType>; - return React.createElement(Component, { ...props, ...labels }); + return React.createElement(Component, { ...props, ...capitalized }); }; - }; diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index 1b77d8cf19..4a9637a358 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -4,7 +4,7 @@ import i18n from '@dhis2/d2-i18n'; import { DataEntryWidgetOutputComponent } from './DataEntryWidgetOutput.component'; import { getDataEntryKey } from '../DataEntry/common/getDataEntryKey'; import { makeProgramRulesSelector } from './DataEntryWidgetOutput.selectors'; -import { getTermLabel } from '../../metaData'; +import { getTermLabel, LabelKeys } from '../../metaData'; type OwnProps = { dataEntryId: string; @@ -18,8 +18,8 @@ const makeMapStateToProps = () => { const { dataEntries } = state; const ready = !!dataEntries[dataEntryId]; const dataEntryKey = ready ? getDataEntryKey(dataEntryId, state.dataEntries[dataEntryId].itemId) : null; - // Example use of getTermLabel: programId comes from the container's selectedScopeId prop. - const enrollmentLabel = getTermLabel('enrollment', { programId: selectedScopeId }); + // Example use of getTermLabel. + const { enrollmentLabel } = getTermLabel([LabelKeys.enrollment], { programId: selectedScopeId }); return { ready, diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index e0b3f4e95d..7f7c2056b6 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -17,7 +17,7 @@ import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes, getTermLabelFromProgram } from '../../metaData'; +import { dataElementTypes, getTermLabelFromProgram, LabelKeys } from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -94,10 +94,11 @@ const WidgetEnrollmentPlain = ({ const orgUnitClientValue = { id: enrollment?.orgUnit, name: orgUnitName, ancestors }; const ownerOrgUnitClientValue = { id: ownerOrgUnit?.id, name: ownerOrgUnitName, ancestors: ownerAncestors }; - // Example use of getTermLabelFromProgram: resolves the "enrollment" term against the - // widget's own fetched program. Keeps the widget self-contained (no dependency on - // the global programCollection memory store or Capture Redux state). - const enrollmentLabel = getTermLabelFromProgram('enrollment', { program }); + // Example use of getTermLabelFromProgram. + const { enrollmentLabel, followUpLabel } = getTermLabelFromProgram( + [LabelKeys.enrollment, LabelKeys.followUp], + { program }, + ); return (

@@ -130,7 +131,7 @@ const WidgetEnrollmentPlain = ({
{enrollment.followUp && ( - {i18n.t('Follow-up')} + {followUpLabel} )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx index 88a7e0efaa..8202a663a0 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx @@ -147,7 +147,7 @@ const buildOrgUnitSettingsFn = () => { const orgUnitSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - // Example use of withCustomLabels: orgUnitLabel is injected as a prop by the HOC, resolved against the current program's custom terminology. + // Example use of withCustomLabels. label: props.orgUnitLabel ?? i18n.t('Organisation unit'), valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), @@ -224,7 +224,7 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - // Example use of withCustomLabels: eventLabel is injected as a prop by the HOC, resolved against the current program's custom terminology. + // Example use of withCustomLabels. label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel ?? i18n.t('Event') }), id: dataElement.id, valueConverter: value => dataElement.convertValue(value, valueConvertFn), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts index 60f11c27db..de414cc521 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts @@ -2,14 +2,10 @@ import { connect } from 'react-redux'; import { ViewEventDataEntryComponent } from './ViewEventDataEntry.component'; import { withLoadingIndicator } from '../../../HOC/withLoadingIndicator'; import { withCustomLabels } from '../../../HOC/withCustomLabels'; +import { LabelKeys } from '../../../metaData'; -// Example use of withCustomLabels: injects `orgUnitLabel` and `eventLabel` as -// props, resolved against the current program's custom terminology (programId -// is supplied via mapStateToProps below). -const customLabels = { - orgUnitLabel: { key: 'orgUnit' }, - eventLabel: { key: 'event' }, -} as const; +// Example use of withCustomLabels. +const customLabels = [LabelKeys.orgUnit, LabelKeys.event] as const; const mapStateToProps = (state: any, props: any) => { const eventDetailsSection = state.viewEventPage.eventDetailsSection || {}; 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 5f34f80285..752626dc96 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -6,7 +6,7 @@ import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { Stages } from './Stages'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; -import { useTermLabel } from '../../metaData'; +import { useTermLabel, LabelKeys } from '../../metaData'; import type { Props } from './stagesAndEvents.types'; const styles = { @@ -36,10 +36,12 @@ const WidgetStagesAndEventsPlain = ({ multipleStages, showWidgetBadge, } = useEnrollmentAccessContext(); - // Example use of useTermLabel: resolves the plural program-stage and event - // labels against the current program's custom terminology. - const programStagesLabel = useTermLabel('programStage', { programId, plural: true }); - const eventsLabel = useTermLabel('event', { programId, plural: true }); + // Example use of useTermLabel. + const { programStagesLabel, eventsLabel } = useTermLabel( + [{ key: LabelKeys.programStage, plural: true }, + { key: LabelKeys.event, plural: true }], + { programId }, + ); return (
; -type LabelOptions = { plural?: boolean }; + +export const LabelKeys = { + enrollment: 'enrollment', + event: 'event', + programStage: 'programStage', + note: 'note', + relationship: 'relationship', + attribute: 'attribute', + orgUnit: 'orgUnit', + followUp: 'followUp', +} as const satisfies { [K in CustomLabelKey]: K }; + +export type TermRequest = + | CustomLabelKey + | { key: CustomLabelKey; plural?: boolean }; + +type LabelSource = Record | undefined | null; +type GetTermLabelFromProgramOptions = { program: LabelSource }; +type GetTermLabelOptions = { programId: string | null | undefined; stageId?: string | null }; +type UseTermLabelOptions = { programId?: string | null; stageId?: string | null }; const ALL_FIELD_NAMES = Object.values(LABELS).flatMap( ({ field, pluralField }) => (pluralField ? [field, pluralField] : [field]), ); -export const extractCustomLabels = (cached: Record): CustomLabels => - Object.fromEntries( - ALL_FIELD_NAMES - .filter(field => typeof cached[field] === 'string') - .map(field => [field, cached[field] as string]), - ); - -type LabelSource = CustomLabels | undefined | null; +const resolveDefault = (key: CustomLabelKey, plural: boolean): string => { + const label = LABELS[key]; + return plural ? label.plural?.() ?? label.singular() : label.singular(); +}; const resolveLabel = ( - sources: LabelSource | Array, + sources: ReadonlyArray, key: CustomLabelKey, - { plural = false }: LabelOptions = {}, -): string | undefined => { + plural: boolean, +): string => { const { field, pluralField } = LABELS[key]; const target = plural ? pluralField : field; - if (!target) return undefined; - const list = Array.isArray(sources) ? sources : [sources]; - return list.find(source => source?.[target])?.[target]; + const found = target + ? sources + .map(source => source?.[target]) + .find((value): value is string => typeof value === 'string') + : undefined; + return found ?? resolveDefault(key, plural); }; -type BaseTermOptions = LabelOptions & { stageId?: string | null }; -type GetTermLabelOptions = BaseTermOptions & { programId: string }; -type UseTermLabelOptions = BaseTermOptions & { programId?: string | null }; -type GetTermLabelFromProgramOptions = LabelOptions & { program: Record | null | undefined; }; - -const resolveTerm = ( +const resolveFromCollection = ( programId: string | null | undefined, + stageId: string | null | undefined, key: CustomLabelKey, - { stageId, plural = false }: BaseTermOptions, + plural: boolean, ): string => { const program = programId ? programCollection.get(programId) : undefined; const stage = program && stageId ? program.getStage(stageId) : undefined; - const custom = resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - if (custom) return custom; - if (plural) return LABELS[key].plural?.() ?? LABELS[key].singular(); - return LABELS[key].singular(); + return resolveLabel([stage?.customLabels, program?.customLabels], key, plural); }; -export const getTermLabelFromProgram = ( - key: CustomLabelKey, - { program, plural = false }: GetTermLabelFromProgramOptions, -): string => { - const { field, pluralField, singular } = LABELS[key]; - const target = plural ? pluralField : field; - const value = target ? program?.[target] : undefined; - if (typeof value === 'string') return value; - if (plural) return LABELS[key].plural?.() ?? singular(); - return singular(); +const buildLabels = ( + requests: ReadonlyArray, + resolve: (key: CustomLabelKey, plural: boolean) => string, +): CustomLabels => { + const entries = requests.map((req) => { + const { key, plural } = typeof req === 'string' + ? { key: req, plural: false } + : { key: req.key, plural: req.plural ?? false }; + const outputKey = plural ? `${key}sLabel` : `${key}Label`; + return [outputKey, resolve(key, plural)]; + }); + return Object.fromEntries(entries); }; +export const extractCustomLabels = (cached: Record): CustomLabels => + Object.fromEntries( + ALL_FIELD_NAMES + .filter(field => typeof cached[field] === 'string') + .map(field => [field, cached[field] as string]), + ); + export const getTermLabel = ( - key: CustomLabelKey, - options: GetTermLabelOptions, -): string => resolveTerm(options.programId, key, options); + requests: ReadonlyArray, + { programId, stageId }: GetTermLabelOptions, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveFromCollection(programId, stageId, key, plural)); + +export const getTermLabelFromProgram = ( + requests: ReadonlyArray, + { program }: GetTermLabelFromProgramOptions, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveLabel([program], key, plural)); export const useTermLabel = ( - key: CustomLabelKey, - options: UseTermLabelOptions = {}, -): string => { - const { programId, stageId, plural } = options; - const id = useSelector(({ currentSelections }: any) => + requests: ReadonlyArray, + { programId, stageId }: UseTermLabelOptions = {}, +): CustomLabels => { + const activeProgramId = useSelector(({ currentSelections }: any) => programId ?? currentSelections.programId); - return useMemo( - () => resolveTerm(id, key, { stageId, plural }), - [id, key, stageId, plural], - ); + return buildLabels(requests, (key, plural) => + resolveFromCollection(activeProgramId, stageId, 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 fdf6a81de4..ca01d7bf49 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -19,8 +19,12 @@ export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { extractCustomLabels, - getTermLabelFromProgram, getTermLabel, + getTermLabelFromProgram, + LabelKeys, useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './customLabels'; -export type { CustomLabelKey, CustomLabels } from './customLabels'; + diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 412788a253..917eca2738 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -41,8 +41,11 @@ export { getProgramAndStageForEventProgram, getEventProgramEventAccess, extractCustomLabels, - getTermLabelFromProgram, getTermLabel, + getTermLabelFromProgram, + LabelKeys, useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './helpers'; -export type { CustomLabelKey, CustomLabels } from './helpers'; From 906d642ff5b8fd73208804260c5d6c05797fc8e6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:50:59 +0000 Subject: [PATCH 52/57] fix: clean up --- i18n/en.pot | 7 +++++-- .../WidgetEnrollment/WidgetEnrollment.component.tsx | 2 +- .../WidgetEventEdit/WidgetEventEdit.container.tsx | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index efffdf6370..8420d35d76 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T09:52:56.280Z\n" -"PO-Revision-Date: 2026-09-06T09:52:56.281Z\n" +"POT-Creation-Date: 2026-09-06T10:51:01.054Z\n" +"PO-Revision-Date: 2026-09-06T10:51:01.054Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1458,6 +1458,9 @@ msgstr "{{enrollmentLabel}}" msgid "Enrollment widget could not be loaded. Please try again later" msgstr "Enrollment widget could not be loaded. Please try again later" +msgid "{{followUpLabel}}" +msgstr "{{followUpLabel}}" + msgid "Started at{{escape}}" msgstr "Started at{{escape}}" diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 7f7c2056b6..3a68fbabc8 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -131,7 +131,7 @@ const WidgetEnrollmentPlain = ({
{enrollment.followUp && ( - {followUpLabel} + {i18n.t('{{followUpLabel}}', { followUpLabel })} )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index f6ab983675..6a4935c7ec 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -152,6 +152,7 @@ const WidgetEventEditPlain = ({ > Date: Sun, 6 Sep 2026 12:12:00 +0000 Subject: [PATCH 53/57] feat: refine terminology support by updating label keys to singular and plural --- i18n/en.pot | 4 +-- .../DataEntryWidgetOutput.container.ts | 2 +- .../WidgetEnrollment.component.tsx | 2 +- .../ViewEventDataEntry.container.ts | 2 +- .../WidgetStagesAndEvents.component.tsx | 3 +- .../metaData/helpers/customLabels.ts | 28 +++++++++++++------ 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8420d35d76..af8a1eb80c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T10:51:01.054Z\n" -"PO-Revision-Date: 2026-09-06T10:51:01.054Z\n" +"POT-Creation-Date: 2026-09-06T12:12:03.892Z\n" +"PO-Revision-Date: 2026-09-06T12:12:03.892Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index 4a9637a358..9459591de7 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -19,7 +19,7 @@ const makeMapStateToProps = () => { const ready = !!dataEntries[dataEntryId]; const dataEntryKey = ready ? getDataEntryKey(dataEntryId, state.dataEntries[dataEntryId].itemId) : null; // Example use of getTermLabel. - const { enrollmentLabel } = getTermLabel([LabelKeys.enrollment], { programId: selectedScopeId }); + const { enrollmentLabel } = getTermLabel([LabelKeys.enrollmentSingular], { programId: selectedScopeId }); return { ready, diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 3a68fbabc8..c0b6b48aa5 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -96,7 +96,7 @@ const WidgetEnrollmentPlain = ({ const ownerOrgUnitClientValue = { id: ownerOrgUnit?.id, name: ownerOrgUnitName, ancestors: ownerAncestors }; // Example use of getTermLabelFromProgram. const { enrollmentLabel, followUpLabel } = getTermLabelFromProgram( - [LabelKeys.enrollment, LabelKeys.followUp], + [LabelKeys.enrollmentSingular, LabelKeys.followUpSingular], { program }, ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts index de414cc521..41642b64ef 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts @@ -5,7 +5,7 @@ import { withCustomLabels } from '../../../HOC/withCustomLabels'; import { LabelKeys } from '../../../metaData'; // Example use of withCustomLabels. -const customLabels = [LabelKeys.orgUnit, LabelKeys.event] as const; +const customLabels = [LabelKeys.orgUnitSingular, LabelKeys.eventSingular] as const; const mapStateToProps = (state: any, props: any) => { const eventDetailsSection = state.viewEventPage.eventDetailsSection || {}; 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 752626dc96..43489bb373 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/WidgetStagesAndEvents.component.tsx @@ -38,8 +38,7 @@ const WidgetStagesAndEventsPlain = ({ } = useEnrollmentAccessContext(); // Example use of useTermLabel. const { programStagesLabel, eventsLabel } = useTermLabel( - [{ key: LabelKeys.programStage, plural: true }, - { key: LabelKeys.event, plural: true }], + [LabelKeys.programStagePlural, LabelKeys.eventPlural], { programId }, ); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index e90054745d..87065a60a2 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -56,15 +56,25 @@ export type CustomLabelKey = keyof typeof LABELS; export type CustomLabels = Record; export const LabelKeys = { - enrollment: 'enrollment', - event: 'event', - programStage: 'programStage', - note: 'note', - relationship: 'relationship', - attribute: 'attribute', - orgUnit: 'orgUnit', - followUp: 'followUp', -} as const satisfies { [K in CustomLabelKey]: K }; + enrollmentSingular: 'enrollment', + enrollmentPlural: { key: 'enrollment', plural: true }, + eventSingular: 'event', + eventPlural: { key: 'event', plural: true }, + programStageSingular: 'programStage', + programStagePlural: { key: 'programStage', plural: true }, + noteSingular: 'note', + notePlural: { key: 'note', plural: true }, + relationshipSingular: 'relationship', + relationshipPlural: { key: 'relationship', plural: true }, + attributeSingular: 'attribute', + attributePlural: { key: 'attribute', plural: true }, + orgUnitSingular: 'orgUnit', + orgUnitPlural: { key: 'orgUnit', plural: true }, + followUpSingular: 'followUp', + followUpPlural: { key: 'followUp', plural: true }, +} as const satisfies + & { [K in CustomLabelKey as `${K}Singular`]: K } + & { [K in CustomLabelKey as `${K}Plural`]: { key: K; plural: true } }; export type TermRequest = | CustomLabelKey From f312f01e52464020eb72bff59d3126b737010517 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:59:08 +0000 Subject: [PATCH 54/57] feat: refine label configuration by updating field definitions and introducing apiField properties --- i18n/en.pot | 4 +- .../metaData/helpers/customLabels.ts | 86 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index af8a1eb80c..4cf227544f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T12:12:03.892Z\n" -"PO-Revision-Date: 2026-09-06T12:12:03.892Z\n" +"POT-Creation-Date: 2026-09-06T12:59:10.124Z\n" +"PO-Revision-Date: 2026-09-06T12:59:10.124Z\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.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index 87065a60a2..88079c948b 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -3,58 +3,60 @@ import { useSelector } from 'react-redux'; import { programCollection } from '../../metaDataMemoryStores'; type LabelConfig = { - field: string; - pluralField?: string; - singular: () => string; - plural?: () => string; + apiFieldSingular: string; + apiFieldPlural?: string; + defaultSingular: () => string; + defaultPlural?: () => string; }; -const asLabels = (labels: Record) => labels; - -const LABELS = asLabels({ +const LABELS = { enrollment: { - field: 'displayEnrollmentLabel', - pluralField: 'displayEnrollmentsLabel', - singular: () => i18n.t('enrollment'), - plural: () => i18n.t('enrollments'), + apiFieldSingular: 'displayEnrollmentLabel', + apiFieldPlural: 'displayEnrollmentsLabel', + defaultSingular: () => i18n.t('enrollment'), + defaultPlural: () => i18n.t('enrollments'), }, event: { - field: 'displayEventLabel', - pluralField: 'displayEventsLabel', - singular: () => i18n.t('event'), - plural: () => i18n.t('events'), + apiFieldSingular: 'displayEventLabel', + apiFieldPlural: 'displayEventsLabel', + defaultSingular: () => i18n.t('event'), + defaultPlural: () => i18n.t('events'), }, programStage: { - field: 'displayProgramStageLabel', - pluralField: 'displayProgramStagesLabel', - singular: () => i18n.t('program stage'), - plural: () => i18n.t('program stages'), + apiFieldSingular: 'displayProgramStageLabel', + apiFieldPlural: 'displayProgramStagesLabel', + defaultSingular: () => i18n.t('program stage'), + defaultPlural: () => i18n.t('program stages'), }, note: { - field: 'displayNoteLabel', - singular: () => i18n.t('note'), + apiFieldSingular: 'displayNoteLabel', + defaultSingular: () => i18n.t('note'), }, relationship: { - field: 'displayRelationshipLabel', - singular: () => i18n.t('relationship'), + apiFieldSingular: 'displayRelationshipLabel', + defaultSingular: () => i18n.t('relationship'), }, attribute: { - field: 'displayTrackedEntityAttributeLabel', - singular: () => i18n.t('attribute'), + apiFieldSingular: 'displayTrackedEntityAttributeLabel', + defaultSingular: () => i18n.t('attribute'), }, orgUnit: { - field: 'displayOrgUnitLabel', - singular: () => i18n.t('organisation unit'), + apiFieldSingular: 'displayOrgUnitLabel', + defaultSingular: () => i18n.t('organisation unit'), }, followUp: { - field: 'displayFollowUpLabel', - singular: () => i18n.t('follow-up'), + apiFieldSingular: 'displayFollowUpLabel', + defaultSingular: () => i18n.t('follow-up'), }, -}); +} satisfies Record; export type CustomLabelKey = keyof typeof LABELS; export type CustomLabels = Record; +type KeysWithPlural = { + [K in CustomLabelKey]: typeof LABELS[K] extends { apiFieldPlural: string } ? K : never +}[CustomLabelKey]; + export const LabelKeys = { enrollmentSingular: 'enrollment', enrollmentPlural: { key: 'enrollment', plural: true }, @@ -63,35 +65,33 @@ export const LabelKeys = { programStageSingular: 'programStage', programStagePlural: { key: 'programStage', plural: true }, noteSingular: 'note', - notePlural: { key: 'note', plural: true }, relationshipSingular: 'relationship', - relationshipPlural: { key: 'relationship', plural: true }, attributeSingular: 'attribute', - attributePlural: { key: 'attribute', plural: true }, orgUnitSingular: 'orgUnit', - orgUnitPlural: { key: 'orgUnit', plural: true }, followUpSingular: 'followUp', - followUpPlural: { key: 'followUp', plural: true }, } as const satisfies & { [K in CustomLabelKey as `${K}Singular`]: K } - & { [K in CustomLabelKey as `${K}Plural`]: { key: K; plural: true } }; + & Partial<{ [K in KeysWithPlural as `${K}Plural`]: { key: K; plural: true } }>; export type TermRequest = | CustomLabelKey - | { key: CustomLabelKey; plural?: boolean }; + | { key: KeysWithPlural; plural: true } + | { key: CustomLabelKey; plural?: false }; type LabelSource = Record | undefined | null; type GetTermLabelFromProgramOptions = { program: LabelSource }; type GetTermLabelOptions = { programId: string | null | undefined; stageId?: string | null }; type UseTermLabelOptions = { programId?: string | null; stageId?: string | null }; -const ALL_FIELD_NAMES = Object.values(LABELS).flatMap( - ({ field, pluralField }) => (pluralField ? [field, pluralField] : [field]), +const getLabel = (key: CustomLabelKey): LabelConfig => LABELS[key]; + +const ALL_FIELD_NAMES = Object.values(LABELS as Record).flatMap( + ({ apiFieldSingular, apiFieldPlural }) => (apiFieldPlural ? [apiFieldSingular, apiFieldPlural] : [apiFieldSingular]), ); const resolveDefault = (key: CustomLabelKey, plural: boolean): string => { - const label = LABELS[key]; - return plural ? label.plural?.() ?? label.singular() : label.singular(); + const label = getLabel(key); + return plural ? label.defaultPlural?.() ?? label.defaultSingular() : label.defaultSingular(); }; const resolveLabel = ( @@ -99,8 +99,8 @@ const resolveLabel = ( key: CustomLabelKey, plural: boolean, ): string => { - const { field, pluralField } = LABELS[key]; - const target = plural ? pluralField : field; + const { apiFieldSingular, apiFieldPlural } = getLabel(key); + const target = plural ? apiFieldPlural : apiFieldSingular; const found = target ? sources .map(source => source?.[target]) From a73d3525a39c59e47dda4d9f397287f88abb7489 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:37:14 +0000 Subject: [PATCH 55/57] feat: refactoring label handling and introducing constants for label configurations --- i18n/en.pot | 7 +- .../capture-core/HOC/withCustomLabels.tsx | 10 +- .../ViewEventDataEntry.component.tsx | 4 +- .../helpers/constants/customLabels.const.ts | 63 +++++++++++ .../metaData/helpers/customLabels.ts | 106 ++++-------------- .../capture-core/metaData/helpers/index.ts | 2 +- src/i18n/setupFormatters.ts | 23 ++-- 7 files changed, 102 insertions(+), 113 deletions(-) create mode 100644 src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts diff --git a/i18n/en.pot b/i18n/en.pot index 4cf227544f..b4b09aefa0 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-06T12:59:10.124Z\n" -"PO-Revision-Date: 2026-09-06T12:59:10.124Z\n" +"POT-Creation-Date: 2026-09-07T08:37:16.917Z\n" +"PO-Revision-Date: 2026-09-07T08:37:16.917Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1545,9 +1545,6 @@ msgstr "No polygon captured" msgid "{{eventLabel}} completed" msgstr "{{eventLabel}} completed" -msgid "Event" -msgstr "Event" - msgid "Notes about this event" msgstr "Notes about this event" diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx index 32f3c40398..912cffde7c 100644 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -1,17 +1,15 @@ import * as React from 'react'; import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; -import { useTermLabel } from '../metaData'; -import type { TermRequest } from '../metaData/helpers/customLabels'; +import { useTermLabel, type TermRequest } from '../metaData'; export const withCustomLabels = (requests: ReadonlyArray) => -

>(WrappedComponent: React.ComponentType

) => - (props: P & { programId?: string; stageId?: string }) => { + (InnerComponent: React.ComponentType) => + (props: any) => { const { programId, stageId } = props; const labels = useTermLabel(requests, { programId, stageId }); const capitalized = Object.fromEntries( Object.entries(labels).map(([key, value]) => [key, capitalizeFirstLetter(value)]), ); - const Component = WrappedComponent as React.ComponentType>; - return React.createElement(Component, { ...props, ...capitalized }); + return ; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx index 8202a663a0..d6acac7e3b 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx @@ -148,7 +148,7 @@ const buildOrgUnitSettingsFn = () => { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { // Example use of withCustomLabels. - label: props.orgUnitLabel ?? i18n.t('Organisation unit'), + label: props.orgUnitLabel, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), getPropName: () => 'orgUnit', @@ -225,7 +225,7 @@ const buildCompleteFieldSettingsFn = () => { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { // Example use of withCustomLabels. - label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel ?? i18n.t('Event') }), + label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel }), id: dataElement.id, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), diff --git a/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts new file mode 100644 index 0000000000..67ead94aa8 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts @@ -0,0 +1,63 @@ +import i18n from '@dhis2/d2-i18n'; + +export type LabelConfig = { + apiFieldSingular: string; + apiFieldPlural?: string; + defaultSingular: () => string; + defaultPlural?: () => string; +}; + +export const LABELS = { + enrollment: { + apiFieldSingular: 'displayEnrollmentLabel', + apiFieldPlural: 'displayEnrollmentsLabel', + defaultSingular: () => i18n.t('enrollment'), + defaultPlural: () => i18n.t('enrollments'), + }, + event: { + apiFieldSingular: 'displayEventLabel', + apiFieldPlural: 'displayEventsLabel', + defaultSingular: () => i18n.t('event'), + defaultPlural: () => i18n.t('events'), + }, + programStage: { + apiFieldSingular: 'displayProgramStageLabel', + apiFieldPlural: 'displayProgramStagesLabel', + defaultSingular: () => i18n.t('program stage'), + defaultPlural: () => i18n.t('program stages'), + }, + note: { + apiFieldSingular: 'displayNoteLabel', + defaultSingular: () => i18n.t('note'), + }, + relationship: { + apiFieldSingular: 'displayRelationshipLabel', + defaultSingular: () => i18n.t('relationship'), + }, + attribute: { + apiFieldSingular: 'displayTrackedEntityAttributeLabel', + defaultSingular: () => i18n.t('attribute'), + }, + orgUnit: { + apiFieldSingular: 'displayOrgUnitLabel', + defaultSingular: () => i18n.t('organisation unit'), + }, + followUp: { + apiFieldSingular: 'displayFollowUpLabel', + defaultSingular: () => i18n.t('follow-up'), + }, +} satisfies Record; + +export const LabelKeys = { + enrollmentSingular: 'enrollment', + enrollmentPlural: { key: 'enrollment', plural: true }, + eventSingular: 'event', + eventPlural: { key: 'event', plural: true }, + programStageSingular: 'programStage', + programStagePlural: { key: 'programStage', plural: true }, + noteSingular: 'note', + relationshipSingular: 'relationship', + attributeSingular: 'attribute', + orgUnitSingular: 'orgUnit', + followUpSingular: 'followUp', +} as const; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts index 88079c948b..3266cc270e 100644 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -1,87 +1,16 @@ -import i18n from '@dhis2/d2-i18n'; import { useSelector } from 'react-redux'; import { programCollection } from '../../metaDataMemoryStores'; - -type LabelConfig = { - apiFieldSingular: string; - apiFieldPlural?: string; - defaultSingular: () => string; - defaultPlural?: () => string; -}; - -const LABELS = { - enrollment: { - apiFieldSingular: 'displayEnrollmentLabel', - apiFieldPlural: 'displayEnrollmentsLabel', - defaultSingular: () => i18n.t('enrollment'), - defaultPlural: () => i18n.t('enrollments'), - }, - event: { - apiFieldSingular: 'displayEventLabel', - apiFieldPlural: 'displayEventsLabel', - defaultSingular: () => i18n.t('event'), - defaultPlural: () => i18n.t('events'), - }, - programStage: { - apiFieldSingular: 'displayProgramStageLabel', - apiFieldPlural: 'displayProgramStagesLabel', - defaultSingular: () => i18n.t('program stage'), - defaultPlural: () => i18n.t('program stages'), - }, - note: { - apiFieldSingular: 'displayNoteLabel', - defaultSingular: () => i18n.t('note'), - }, - relationship: { - apiFieldSingular: 'displayRelationshipLabel', - defaultSingular: () => i18n.t('relationship'), - }, - attribute: { - apiFieldSingular: 'displayTrackedEntityAttributeLabel', - defaultSingular: () => i18n.t('attribute'), - }, - orgUnit: { - apiFieldSingular: 'displayOrgUnitLabel', - defaultSingular: () => i18n.t('organisation unit'), - }, - followUp: { - apiFieldSingular: 'displayFollowUpLabel', - defaultSingular: () => i18n.t('follow-up'), - }, -} satisfies Record; +import { LABELS, type LabelConfig } from './constants/customLabels.const'; export type CustomLabelKey = keyof typeof LABELS; export type CustomLabels = Record; - -type KeysWithPlural = { - [K in CustomLabelKey]: typeof LABELS[K] extends { apiFieldPlural: string } ? K : never -}[CustomLabelKey]; - -export const LabelKeys = { - enrollmentSingular: 'enrollment', - enrollmentPlural: { key: 'enrollment', plural: true }, - eventSingular: 'event', - eventPlural: { key: 'event', plural: true }, - programStageSingular: 'programStage', - programStagePlural: { key: 'programStage', plural: true }, - noteSingular: 'note', - relationshipSingular: 'relationship', - attributeSingular: 'attribute', - orgUnitSingular: 'orgUnit', - followUpSingular: 'followUp', -} as const satisfies - & { [K in CustomLabelKey as `${K}Singular`]: K } - & Partial<{ [K in KeysWithPlural as `${K}Plural`]: { key: K; plural: true } }>; - -export type TermRequest = - | CustomLabelKey - | { key: KeysWithPlural; plural: true } - | { key: CustomLabelKey; plural?: false }; +export type TermRequest = CustomLabelKey | { key: CustomLabelKey; plural?: boolean }; type LabelSource = Record | undefined | null; -type GetTermLabelFromProgramOptions = { program: LabelSource }; -type GetTermLabelOptions = { programId: string | null | undefined; stageId?: string | null }; -type UseTermLabelOptions = { programId?: string | null; stageId?: string | null }; + +type ProgramScope = { programId: string | null | undefined; stageId?: string | null }; +type OptionalProgramScope = { programId?: string | null; stageId?: string | null }; +type ProgramContainer = { program: LabelSource }; const getLabel = (key: CustomLabelKey): LabelConfig => LABELS[key]; @@ -125,37 +54,42 @@ const buildLabels = ( resolve: (key: CustomLabelKey, plural: boolean) => string, ): CustomLabels => { const entries = requests.map((req) => { - const { key, plural } = typeof req === 'string' - ? { key: req, plural: false } - : { key: req.key, plural: req.plural ?? false }; + const isString = typeof req === 'string'; + const key = isString ? req : req.key; + const plural = !isString && (req.plural ?? false); const outputKey = plural ? `${key}sLabel` : `${key}Label`; return [outputKey, resolve(key, plural)]; }); return Object.fromEntries(entries); }; +/** Use in metadata-load code (factories) to pluck label fields from a raw API object. */ export const extractCustomLabels = (cached: Record): CustomLabels => Object.fromEntries( - ALL_FIELD_NAMES - .filter(field => typeof cached[field] === 'string') - .map(field => [field, cached[field] as string]), + ALL_FIELD_NAMES.flatMap((field) => { + const value = cached[field]; + return typeof value === 'string' ? [[field, value]] : []; + }), ); +/** Use outside React (selectors, thunks); reads from `programCollection`. */ export const getTermLabel = ( requests: ReadonlyArray, - { programId, stageId }: GetTermLabelOptions, + { programId, stageId }: ProgramScope, ): CustomLabels => buildLabels(requests, (key, plural) => resolveFromCollection(programId, stageId, key, plural)); +/** Use in self-contained widgets that already own the program object (no Redux dep). */ export const getTermLabelFromProgram = ( requests: ReadonlyArray, - { program }: GetTermLabelFromProgramOptions, + { program }: ProgramContainer, ): CustomLabels => buildLabels(requests, (key, plural) => resolveLabel([program], key, plural)); +/** Use inside React components; `programId` falls back to `currentSelections.programId`. */ export const useTermLabel = ( requests: ReadonlyArray, - { programId, stageId }: UseTermLabelOptions = {}, + { programId, stageId }: OptionalProgramScope = {}, ): CustomLabels => { const activeProgramId = useSelector(({ currentSelections }: any) => programId ?? currentSelections.programId); diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index ca01d7bf49..51ded21356 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -21,10 +21,10 @@ export { extractCustomLabels, getTermLabel, getTermLabelFromProgram, - LabelKeys, useTermLabel, type CustomLabelKey, type CustomLabels, type TermRequest, } from './customLabels'; +export { LabelKeys } from './constants/customLabels.const'; diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts index cfdde0f9c4..432bbac1b5 100644 --- a/src/i18n/setupFormatters.ts +++ b/src/i18n/setupFormatters.ts @@ -23,30 +23,27 @@ const CUSTOM_TERM_VARS = new Set([ 'trackedEntityLabel', 'trackedEntityTypesLabel', ]); -const patchInterpolator = () => { - const interpolator = (i18n as any).services?.interpolator; - if (!interpolator) return; - +const interpolator = (i18n as any).services?.interpolator; +if (interpolator) { const original = interpolator.interpolate.bind(interpolator); + interpolator.interpolate = (template: string, data: Record, lng: string, opts: any) => { const usedVars = [...template.matchAll(/\{\{\s*(\w+)/g)].map(m => m[1]); - const hasCustomTerm = usedVars.some(name => CUSTOM_TERM_VARS.has(name)); - if (!hasCustomTerm) return original(template, data, lng, opts); + if (!usedVars.some(name => CUSTOM_TERM_VARS.has(name))) { + return original(template, data, lng, opts); + } const leading = /^\{\{\s*(\w+)/.exec(template.trimStart())?.[1]; - const shouldCapitalize = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string'; - const preparedData = shouldCapitalize + const preparedData = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string' ? { ...data, [leading]: capitalizeFirstLetter(data[leading] as string) } : (data ?? {}); - const previousEscapeValue = interpolator.escapeValue; + const previousEscape = interpolator.escapeValue; interpolator.escapeValue = false; try { return original(template, preparedData, lng, opts); } finally { - interpolator.escapeValue = previousEscapeValue; + interpolator.escapeValue = previousEscape; } }; -}; - -patchInterpolator(); +} From 331da5ea6677d39db7246ceb23e9a5437f06250e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:42:08 +0000 Subject: [PATCH 56/57] feat: add consts to support plural of note, relationship, and attribute labels --- i18n/en.pot | 4 ++-- .../metaData/helpers/constants/customLabels.const.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f481e40fc5..ce603067bb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-07T08:41:24.158Z\n" -"PO-Revision-Date: 2026-09-07T08:41:24.158Z\n" +"POT-Creation-Date: 2026-09-07T08:42:10.308Z\n" +"PO-Revision-Date: 2026-09-07T08:42:10.308Z\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/constants/customLabels.const.ts b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts index 67ead94aa8..9da569f74b 100644 --- a/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts +++ b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts @@ -28,15 +28,21 @@ export const LABELS = { }, note: { apiFieldSingular: 'displayNoteLabel', + apiFieldPlural: 'displayNotesLabel', defaultSingular: () => i18n.t('note'), + defaultPlural: () => i18n.t('notes'), }, relationship: { apiFieldSingular: 'displayRelationshipLabel', + apiFieldPlural: 'displayRelationshipsLabel', defaultSingular: () => i18n.t('relationship'), + defaultPlural: () => i18n.t('relationships'), }, attribute: { apiFieldSingular: 'displayTrackedEntityAttributeLabel', + apiFieldPlural: 'displayTrackedEntityAttributesLabel', defaultSingular: () => i18n.t('attribute'), + defaultPlural: () => i18n.t('attributes'), }, orgUnit: { apiFieldSingular: 'displayOrgUnitLabel', @@ -56,8 +62,11 @@ export const LabelKeys = { programStageSingular: 'programStage', programStagePlural: { key: 'programStage', plural: true }, noteSingular: 'note', + notePlural: { key: 'note', plural: true }, relationshipSingular: 'relationship', + relationshipPlural: { key: 'relationship', plural: true }, attributeSingular: 'attribute', + attributePlural: { key: 'attribute', plural: true }, orgUnitSingular: 'orgUnit', followUpSingular: 'followUp', } as const; From 027ae64524e560e7fbc17641cf0659128c16aa0e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:16:31 +0000 Subject: [PATCH 57/57] feat: refine custom terminology support --- i18n/en.pot | 57 ++++++++--- .../featuresSupport/support.ts | 2 + .../string/capitalizeFirstLetter.ts | 12 ++- .../capture-core/HOC/withCustomLabels.tsx | 15 +++ .../DataEntryWidgetOutput.container.ts | 19 +++- .../WidgetEnrollment.component.tsx | 11 ++- .../WidgetEnrollment/hooks/useProgram.ts | 28 ++++-- .../ViewEventDataEntry.component.tsx | 6 +- .../ViewEventDataEntry.container.ts | 6 +- .../WidgetEventEdit.container.tsx | 1 + .../WidgetStagesAndEvents.component.tsx | 13 ++- .../TrackedEntityType/TrackedEntityType.ts | 10 -- .../helpers/constants/customLabels.const.ts | 63 ++++++++++++ .../metaData/helpers/customLabels.ts | 98 +++++++++++++++++++ .../helpers/customLabels/customLabels.ts | 73 -------------- .../metaData/helpers/customLabels/index.ts | 10 -- .../metaData/helpers/customLabels/useLabel.ts | 45 --------- .../capture-core/metaData/helpers/index.ts | 17 ++-- .../capture-core/metaData/index.ts | 16 ++- .../TrackedEntityTypeFactory.ts | 6 +- .../quickStoreOperations/storePrograms.ts | 43 ++++++-- .../storeTrackedEntityTypes.ts | 5 +- src/i18n/setupFormatters.ts | 48 +++++++++ src/index.tsx | 1 + 24 files changed, 396 insertions(+), 209 deletions(-) create mode 100644 src/core_modules/capture-core/HOC/withCustomLabels.tsx create mode 100644 src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/index.ts delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts create mode 100644 src/i18n/setupFormatters.ts diff --git a/i18n/en.pot b/i18n/en.pot index 424ac8f969..1d10f8f28b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-31T05:02:39.077Z\n" -"PO-Revision-Date: 2026-08-31T05:02:39.077Z\n" +"POT-Creation-Date: 2026-09-07T13:16:33.923Z\n" +"PO-Revision-Date: 2026-09-07T13:16:33.923Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -424,11 +424,11 @@ msgstr "Some operations are still running. Please wait." msgid "Operations running" msgstr "Operations running" -msgid "No feedback for this enrollment yet" -msgstr "No feedback for this enrollment yet" +msgid "No feedback for this {{enrollmentLabel}} yet" +msgstr "No feedback for this {{enrollmentLabel}} yet" -msgid "No indicator output for this enrollment yet" -msgstr "No indicator output for this enrollment yet" +msgid "No indicator output for this {{enrollmentLabel}} yet" +msgstr "No indicator output for this {{enrollmentLabel}} yet" msgid "" "This event has unsaved changes. Leaving this page without saving will lose " @@ -718,6 +718,12 @@ msgstr "Notice" msgid "Close the notice" msgstr "Close the notice" +msgid "No feedback for this enrollment yet" +msgstr "No feedback for this enrollment yet" + +msgid "No indicator output for this enrollment yet" +msgstr "No indicator output for this enrollment yet" + msgid "Quick actions" msgstr "Quick actions" @@ -1446,11 +1452,14 @@ msgstr "Enrollment date" msgid "Incident date" msgstr "Incident date" +msgid "{{enrollmentLabel}}" +msgstr "{{enrollmentLabel}}" + msgid "Enrollment widget could not be loaded. Please try again later" msgstr "Enrollment widget could not be loaded. Please try again later" -msgid "Follow-up" -msgstr "Follow-up" +msgid "{{followUpLabel}}" +msgstr "{{followUpLabel}}" msgid "Started at{{escape}}" msgstr "Started at{{escape}}" @@ -1533,8 +1542,8 @@ msgstr "Polygon captured" msgid "No polygon captured" msgstr "No polygon captured" -msgid "Event completed" -msgstr "Event completed" +msgid "{{eventLabel}} completed" +msgstr "{{eventLabel}} completed" msgid "Notes about this event" msgstr "Notes about this event" @@ -1807,8 +1816,8 @@ msgstr "{{ scheduledEvents }} scheduled" msgid "No stages found in this program" msgstr "No stages found in this program" -msgid "Stages and Events" -msgstr "Stages and Events" +msgid "{{programStagesLabel}} and {{eventsLabel}}" +msgstr "{{programStagesLabel}} and {{eventsLabel}}" msgid "An error occurred while unlinking and deleting the event." msgstr "An error occurred while unlinking and deleting the event." @@ -2248,6 +2257,30 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" +msgid "enrollments" +msgstr "enrollments" + +msgid "program stage" +msgstr "program stage" + +msgid "program stages" +msgstr "program stages" + +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-utils/featuresSupport/support.ts b/src/core_modules/capture-core-utils/featuresSupport/support.ts index baeb663dae..90274517ae 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.ts +++ b/src/core_modules/capture-core-utils/featuresSupport/support.ts @@ -6,6 +6,7 @@ export const FEATURES = Object.freeze({ orgUnitReplaceOuQueryParam: 'orgUnitReplaceOuQueryParam', enrollmentStatusReplaceProgramStatusQueryParam: 'enrollmentStatusReplaceProgramStatusQueryParam', emptyValueFilter: 'emptyValueFilter', + customTerminologyPlurals: 'customTerminologyPlurals', }); const MINOR_VERSION_SUPPORT = Object.freeze({ @@ -16,6 +17,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-utils/string/capitalizeFirstLetter.ts b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts index 0184db6f88..fdbe04456c 100644 --- a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts +++ b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts @@ -1,5 +1,11 @@ +import i18n from '@dhis2/d2-i18n'; + export function capitalizeFirstLetter(text: string) { - const first = text.charAt(0).toLocaleUpperCase(); - const rest = text.slice(1); - return first + rest; + if (!text) return text; + const locale = (i18n as any).language ?? 'en'; + try { + return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1); + } catch { + return text.charAt(0).toUpperCase() + text.slice(1); + } } diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx new file mode 100644 index 0000000000..912cffde7c --- /dev/null +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; +import { useTermLabel, type TermRequest } from '../metaData'; + +export const withCustomLabels = + (requests: ReadonlyArray) => + (InnerComponent: React.ComponentType) => + (props: any) => { + const { programId, stageId } = props; + const labels = useTermLabel(requests, { programId, stageId }); + const capitalized = Object.fromEntries( + Object.entries(labels).map(([key, value]) => [key, capitalizeFirstLetter(value)]), + ); + return ; + }; diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index bca310bb95..9459591de7 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -4,6 +4,7 @@ import i18n from '@dhis2/d2-i18n'; import { DataEntryWidgetOutputComponent } from './DataEntryWidgetOutput.component'; import { getDataEntryKey } from '../DataEntry/common/getDataEntryKey'; import { makeProgramRulesSelector } from './DataEntryWidgetOutput.selectors'; +import { getTermLabel, LabelKeys } from '../../metaData'; type OwnProps = { dataEntryId: string; @@ -17,18 +18,26 @@ const makeMapStateToProps = () => { const { dataEntries } = state; const ready = !!dataEntries[dataEntryId]; const dataEntryKey = ready ? getDataEntryKey(dataEntryId, state.dataEntries[dataEntryId].itemId) : null; + // Example use of getTermLabel. + const { enrollmentLabel } = getTermLabel([LabelKeys.enrollmentSingular], { programId: selectedScopeId }); return { ready, dataEntryKey, programRules: programRulesSelector(state, { dataEntryId, selectedScopeId }), - feedbackEmptyText: i18n.t('No feedback for this enrollment yet'), - indicatorEmptyText: i18n.t('No indicator output for this enrollment yet'), + feedbackEmptyText: i18n.t( + 'No feedback for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), + indicatorEmptyText: i18n.t( + 'No indicator output for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), }; }; }; export const DataEntryWidgetOutput: ComponentType = - connect(makeMapStateToProps, () => ({}))( - (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), - ); + connect(makeMapStateToProps, () => ({}))( + (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), + ); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 29b02e6023..c0b6b48aa5 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -17,7 +17,7 @@ import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes } from '../../metaData'; +import { dataElementTypes, getTermLabelFromProgram, LabelKeys } from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -94,13 +94,18 @@ const WidgetEnrollmentPlain = ({ const orgUnitClientValue = { id: enrollment?.orgUnit, name: orgUnitName, ancestors }; const ownerOrgUnitClientValue = { id: ownerOrgUnit?.id, name: ownerOrgUnitName, ancestors: ownerAncestors }; + // Example use of getTermLabelFromProgram. + const { enrollmentLabel, followUpLabel } = getTermLabelFromProgram( + [LabelKeys.enrollmentSingular, LabelKeys.followUpSingular], + { program }, + ); return (

- {i18n.t('Enrollment')} + {i18n.t('{{enrollmentLabel}}', { enrollmentLabel })} {showWidgetBadge && (
{enrollment.followUp && ( - {i18n.t('Follow-up')} + {i18n.t('{{followUpLabel}}', { followUpLabel })} )} 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..bdf71d067a 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,displayProgramStagesLabel,displayEventsLabel,' + + 'displayNotesLabel,displayRelationshipsLabel,displayTrackedEntityAttributesLabel', +]; + 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, }, }, }), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx index 42edee90ce..d6acac7e3b 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx @@ -147,7 +147,8 @@ const buildOrgUnitSettingsFn = () => { const orgUnitSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Organisation unit'), + // Example use of withCustomLabels. + label: props.orgUnitLabel, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), getPropName: () => 'orgUnit', @@ -223,7 +224,8 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Event completed'), + // Example use of withCustomLabels. + label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel }), id: dataElement.id, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts index c9ecd0d685..41642b64ef 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts @@ -1,7 +1,11 @@ import { connect } from 'react-redux'; import { ViewEventDataEntryComponent } from './ViewEventDataEntry.component'; import { withLoadingIndicator } from '../../../HOC/withLoadingIndicator'; +import { withCustomLabels } from '../../../HOC/withCustomLabels'; +import { LabelKeys } from '../../../metaData'; +// Example use of withCustomLabels. +const customLabels = [LabelKeys.orgUnitSingular, LabelKeys.eventSingular] as const; const mapStateToProps = (state: any, props: any) => { const eventDetailsSection = state.viewEventPage.eventDetailsSection || {}; @@ -20,5 +24,5 @@ const mapStateToProps = (state: any, props: any) => { const mapDispatchToProps = (): any => ({}); export const ViewEventDataEntry = connect(mapStateToProps, mapDispatchToProps)( - withLoadingIndicator()(ViewEventDataEntryComponent), + withLoadingIndicator()(withCustomLabels(customLabels)(ViewEventDataEntryComponent)), ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index f6ab983675..6a4935c7ec 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -152,6 +152,7 @@ const WidgetEventEditPlain = ({ > - {i18n.t('Stages and Events')} + + {i18n.t('{{programStagesLabel}} and {{eventsLabel}}', { + programStagesLabel, + eventsLabel, + })} + {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/metaData/helpers/constants/customLabels.const.ts b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts new file mode 100644 index 0000000000..67ead94aa8 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts @@ -0,0 +1,63 @@ +import i18n from '@dhis2/d2-i18n'; + +export type LabelConfig = { + apiFieldSingular: string; + apiFieldPlural?: string; + defaultSingular: () => string; + defaultPlural?: () => string; +}; + +export const LABELS = { + enrollment: { + apiFieldSingular: 'displayEnrollmentLabel', + apiFieldPlural: 'displayEnrollmentsLabel', + defaultSingular: () => i18n.t('enrollment'), + defaultPlural: () => i18n.t('enrollments'), + }, + event: { + apiFieldSingular: 'displayEventLabel', + apiFieldPlural: 'displayEventsLabel', + defaultSingular: () => i18n.t('event'), + defaultPlural: () => i18n.t('events'), + }, + programStage: { + apiFieldSingular: 'displayProgramStageLabel', + apiFieldPlural: 'displayProgramStagesLabel', + defaultSingular: () => i18n.t('program stage'), + defaultPlural: () => i18n.t('program stages'), + }, + note: { + apiFieldSingular: 'displayNoteLabel', + defaultSingular: () => i18n.t('note'), + }, + relationship: { + apiFieldSingular: 'displayRelationshipLabel', + defaultSingular: () => i18n.t('relationship'), + }, + attribute: { + apiFieldSingular: 'displayTrackedEntityAttributeLabel', + defaultSingular: () => i18n.t('attribute'), + }, + orgUnit: { + apiFieldSingular: 'displayOrgUnitLabel', + defaultSingular: () => i18n.t('organisation unit'), + }, + followUp: { + apiFieldSingular: 'displayFollowUpLabel', + defaultSingular: () => i18n.t('follow-up'), + }, +} satisfies Record; + +export const LabelKeys = { + enrollmentSingular: 'enrollment', + enrollmentPlural: { key: 'enrollment', plural: true }, + eventSingular: 'event', + eventPlural: { key: 'event', plural: true }, + programStageSingular: 'programStage', + programStagePlural: { key: 'programStage', plural: true }, + noteSingular: 'note', + relationshipSingular: 'relationship', + attributeSingular: 'attribute', + orgUnitSingular: 'orgUnit', + followUpSingular: 'followUp', +} as const; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts new file mode 100644 index 0000000000..3266cc270e --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -0,0 +1,98 @@ +import { useSelector } from 'react-redux'; +import { programCollection } from '../../metaDataMemoryStores'; +import { LABELS, type LabelConfig } from './constants/customLabels.const'; + +export type CustomLabelKey = keyof typeof LABELS; +export type CustomLabels = Record; +export type TermRequest = CustomLabelKey | { key: CustomLabelKey; plural?: boolean }; + +type LabelSource = Record | undefined | null; + +type ProgramScope = { programId: string | null | undefined; stageId?: string | null }; +type OptionalProgramScope = { programId?: string | null; stageId?: string | null }; +type ProgramContainer = { program: LabelSource }; + +const getLabel = (key: CustomLabelKey): LabelConfig => LABELS[key]; + +const ALL_FIELD_NAMES = Object.values(LABELS as Record).flatMap( + ({ apiFieldSingular, apiFieldPlural }) => (apiFieldPlural ? [apiFieldSingular, apiFieldPlural] : [apiFieldSingular]), +); + +const resolveDefault = (key: CustomLabelKey, plural: boolean): string => { + const label = getLabel(key); + return plural ? label.defaultPlural?.() ?? label.defaultSingular() : label.defaultSingular(); +}; + +const resolveLabel = ( + sources: ReadonlyArray, + key: CustomLabelKey, + plural: boolean, +): string => { + const { apiFieldSingular, apiFieldPlural } = getLabel(key); + const target = plural ? apiFieldPlural : apiFieldSingular; + const found = target + ? sources + .map(source => source?.[target]) + .find((value): value is string => typeof value === 'string') + : undefined; + return found ?? resolveDefault(key, plural); +}; + +const resolveFromCollection = ( + programId: string | null | undefined, + stageId: string | null | undefined, + key: CustomLabelKey, + plural: boolean, +): string => { + const program = programId ? programCollection.get(programId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + return resolveLabel([stage?.customLabels, program?.customLabels], key, plural); +}; + +const buildLabels = ( + requests: ReadonlyArray, + resolve: (key: CustomLabelKey, plural: boolean) => string, +): CustomLabels => { + const entries = requests.map((req) => { + const isString = typeof req === 'string'; + const key = isString ? req : req.key; + const plural = !isString && (req.plural ?? false); + const outputKey = plural ? `${key}sLabel` : `${key}Label`; + return [outputKey, resolve(key, plural)]; + }); + return Object.fromEntries(entries); +}; + +/** Use in metadata-load code (factories) to pluck label fields from a raw API object. */ +export const extractCustomLabels = (cached: Record): CustomLabels => + Object.fromEntries( + ALL_FIELD_NAMES.flatMap((field) => { + const value = cached[field]; + return typeof value === 'string' ? [[field, value]] : []; + }), + ); + +/** Use outside React (selectors, thunks); reads from `programCollection`. */ +export const getTermLabel = ( + requests: ReadonlyArray, + { programId, stageId }: ProgramScope, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveFromCollection(programId, stageId, key, plural)); + +/** Use in self-contained widgets that already own the program object (no Redux dep). */ +export const getTermLabelFromProgram = ( + requests: ReadonlyArray, + { program }: ProgramContainer, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveLabel([program], key, plural)); + +/** Use inside React components; `programId` falls back to `currentSelections.programId`. */ +export const useTermLabel = ( + requests: ReadonlyArray, + { programId, stageId }: OptionalProgramScope = {}, +): CustomLabels => { + const activeProgramId = useSelector(({ currentSelections }: any) => + programId ?? currentSelections.programId); + return buildLabels(requests, (key, plural) => + resolveFromCollection(activeProgramId, stageId, key, plural)); +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts deleted file mode 100644 index 18938840cd..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ /dev/null @@ -1,73 +0,0 @@ -type CustomLabelField = { - field?: string, - pluralField?: 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' }, -} 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( - new Set( - Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) - .filter((field): field is string => Boolean(field)), - ), -); - -export const extractCustomLabels = (cached: Record): CustomLabels => { - const labels: CustomLabels = {}; - allFields.forEach((field) => { - if (cached[field]) { - labels[field] = cached[field]; - } - }); - return labels; -}; - -type LabelSource = CustomLabels | undefined | null; - -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 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); - -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 deleted file mode 100644 index 49b34132fe..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - CUSTOM_LABEL_FIELDS, - resolveLabel, - extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, -} from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { useProgramLabel, useStageLabel, useTrackedEntityTypeLabel } 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 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..51ded21356 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -18,14 +18,13 @@ export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { - CUSTOM_LABEL_FIELDS, - resolveLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + getTermLabel, + getTermLabelFromProgram, + useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; +export { LabelKeys } from './constants/customLabels.const'; + diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 00e7aca7aa..917eca2738 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -40,14 +40,12 @@ export { getProgramThrowIfNotFound, getProgramAndStageForEventProgram, getEventProgramEventAccess, - CUSTOM_LABEL_FIELDS, - resolveLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + getTermLabel, + getTermLabelFromProgram, + LabelKeys, + useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './helpers'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; 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 5513dce0ee..fb8c442ede 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,7 +81,6 @@ export class TrackedEntityTypeFactory { o.name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - 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 929645433c..8e226e4880 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'; @@ -97,7 +98,7 @@ const programTrackedEntityAttributeFields = [ 'allowFutureDate', ].join(','); -const programStageFields = [ +const baseProgramStageFields = [ 'id', 'access', 'autoGenerateEvent', @@ -117,7 +118,6 @@ const programStageFields = [ 'displayDueDateLabel', 'displayProgramStageLabel', 'displayEventLabel', - 'displayEventsLabel', 'formType', 'featureType', 'validationStrategy', @@ -126,9 +126,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,16 +142,13 @@ const fieldsParam = [ 'displayIncidentDateLabel', 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', - 'displayEnrollmentsLabel', 'displayFollowUpLabel', 'displayOrgUnitLabel', 'displayRelationshipLabel', 'displayNoteLabel', 'displayTrackedEntityAttributeLabel', 'displayProgramStageLabel', - 'displayProgramStagesLabel', 'displayEventLabel', - 'displayEventsLabel', 'minAttributesRequiredToSearch', 'useFirstStageDuringRegistration', 'onlyEnrollOnce', @@ -163,16 +164,38 @@ 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', + 'displayProgramStagesLabel', + 'displayEventsLabel', + 'displayNotesLabel', + 'displayRelationshipsLabel', + 'displayTrackedEntityAttributesLabel', +]; + +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 includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); 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..4271797227 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -26,7 +26,8 @@ const convert = (() => { })); })(); -const fieldsParam = 'id,access,displayName,displayTrackedEntityTypesLabel,minAttributesRequiredToSearch,featureType,' + +const FIELDS = + 'id,access,displayName,minAttributesRequiredToSearch,featureType,' + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + 'translations[property,locale,value]'; @@ -34,7 +35,7 @@ export const storeTrackedEntityTypes = (ids: Array) => { const query = { resource: 'trackedEntityTypes', params: { - fields: fieldsParam, + fields: FIELDS, filter: `id:in:[${ids.join(',')}]`, pageSize: ids.length, }, diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts new file mode 100644 index 0000000000..25e66657b8 --- /dev/null +++ b/src/i18n/setupFormatters.ts @@ -0,0 +1,48 @@ +/** + * Patches d2-i18n's interpolator for custom terminology. + * + * 1. If the template interpolates any variable listed in CUSTOM_TERM_VARS, + * HTML escaping is disabled for that call. + * All other translations keep default HTML escaping (XSS safety). + * 2. If a custom-term variable is the leading token in a template, its value + * is capitalized (locale-aware). + */ + +import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; + +const CUSTOM_TERM_VARS = new Set([ + 'enrollmentLabel', 'enrollmentsLabel', + 'eventLabel', 'eventsLabel', + 'programStageLabel', 'programStagesLabel', + 'linkableStageLabel', + 'followUpLabel', 'orgUnitLabel', + 'relationshipLabel', + 'noteLabel', + 'attributeLabel', +]); + +const interpolator = (i18n as any).services?.interpolator; +if (interpolator) { + const original = interpolator.interpolate.bind(interpolator); + + interpolator.interpolate = (template: string, data: Record, lng: string, opts: any) => { + const usedVars = [...template.matchAll(/\{\{\s*(\w+)/g)].map(m => m[1]); + if (!usedVars.some(name => CUSTOM_TERM_VARS.has(name))) { + return original(template, data, lng, opts); + } + + const leading = /^\{\{\s*(\w+)/.exec(template.trimStart())?.[1]; + const preparedData = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string' + ? { ...data, [leading]: capitalizeFirstLetter(data[leading] as string) } + : (data ?? {}); + + const previousEscape = interpolator.escapeValue; + interpolator.escapeValue = false; + try { + return original(template, preparedData, lng, opts); + } finally { + interpolator.escapeValue = previousEscape; + } + }; +} diff --git a/src/index.tsx b/src/index.tsx index c25ff0c33c..d0f190a285 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; import 'regenerator-runtime'; // To fix the 'regeneratorRuntime is not defined' error comming from react-leaflet-search-unpolyfilled import 'capture-core-utils/extensions/asyncForEachArray'; import 'capture-core-utils/extensions/arrayToHashMap'; +import './i18n/setupFormatters'; import './locales'; //eslint-disable-line import { AppStart } from './components/AppStart';