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/41] 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/41] 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/41] 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/41] 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 03ae46b2ccd529511aa45dd2f2c4fe67a17e69d2 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:14:28 +0000 Subject: [PATCH 05/41] feat: single source for changelog data --- i18n/en.pot | 4 +- .../MenuItems/CompletionMenuItem.tsx | 5 +- .../EnrollmentEditEventPage.container.tsx | 6 +-- .../EventDetailsSection.component.tsx | 8 +--- .../EventDetailsSection.container.ts | 1 - .../EventDetailsSection.types.ts | 1 - .../EventChangelogWrapper.component.tsx | 3 +- .../EventChangelogWrapper.types.ts | 1 - .../WidgetEventEdit.container.tsx | 1 - ...rackedEntityChangelogWrapper.component.tsx | 1 - .../WidgetProfile/WidgetProfile.component.tsx | 5 +- .../EventRow/SkipAction/SkipAction.tsx | 7 ++- .../WidgetEventChangelog.tsx | 3 -- .../WidgetTrackedEntityChangelog.tsx | 3 -- .../common/Changelog/Changelog.container.tsx | 3 -- .../WidgetsChangelog/common/hooks/index.ts | 1 + .../common/hooks/useCurrentEntityValues.ts | 47 +++++++++++++++++++ .../common/hooks/useListDataValues.ts | 20 ++++---- .../common/utils/removeChangelogQueries.ts | 15 ++++++ .../components/WidgetsChangelog/index.ts | 4 ++ 20 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts create mode 100644 src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts diff --git a/i18n/en.pot b/i18n/en.pot index 4f629b284b..51018224b4 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-04T14:35:11.571Z\n" -"PO-Revision-Date: 2026-08-04T14:35:11.571Z\n" +"POT-Creation-Date: 2026-08-05T12:14:30.174Z\n" +"PO-Revision-Date: 2026-08-05T12:14:30.174Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index b5ca3f94e7..73267c2419 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -2,10 +2,11 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { MenuItem, IconCheckmark16, IconUndo16 } from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; type Props = { eventId: string; @@ -25,6 +26,7 @@ export const CompletionMenuItem = ({ onClose, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showError } = useAlert( ({ message }) => message, { critical: true }, @@ -65,6 +67,7 @@ export const CompletionMenuItem = ({ onError?.(); }, onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); onSuccess?.(newStatus); }, }, diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 6339759652..ef33dfa520 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -42,8 +42,7 @@ import { DataStoreKeyByPage, useEnrollmentPageLayout } from '../common/Enrollmen import { DefaultPageLayout } from './PageLayout/DefaultPageLayout.constants'; import { rollbackAssignee, setAssignee } from './EnrollmentEditEventPage.actions'; import { convertClientToServer, convertServerToClient } from '../../../converters'; -import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; -import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; import { statusTypes } from '../../../enrollment'; import { cancelEditEventDataEntry } from '../../WidgetEventEdit/EditEventDataEntry/editEventDataEntry.actions'; import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; @@ -245,8 +244,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ }, [dispatch, navigate, orgUnitId, enrollmentId, eventId]); const onSaveExternal = useCallback(() => { - const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; - queryClient.removeQueries(queryKey); + removeEventChangelogQueries(queryClient, eventId); navigate(`enrollment?${buildUrlQueryString({ orgUnitId, enrollmentId })}`); }, [navigate, orgUnitId, enrollmentId, eventId, queryClient]); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 31ff7d7479..705337e122 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -21,8 +21,7 @@ import { useCoreOrgUnit } from '../../../../metadataRetrieval/coreOrgUnit'; import { NoticeBox } from '../../../NoticeBox'; import { EventChangelogWrapper } from '../../../WidgetEventEdit/EventChangelogWrapper'; import { OverflowButton } from '../../../Buttons'; -import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; -import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; +import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; @@ -64,7 +63,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { classes, eventId, - eventData, onOpenEditEvent, isEditEventPage, programStage, @@ -86,8 +84,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); const onSaveExternal = useCallback(() => { - const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; - queryClient.removeQueries(queryKey); + removeEventChangelogQueries(queryClient, eventId); onBackToAllEvents(); }, [eventId, queryClient, onBackToAllEvents]); @@ -187,7 +184,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts index 52962ab10f..3ac04acd35 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.container.ts @@ -7,7 +7,6 @@ import { const mapStateToProps = (state: any, ownProps: any) => ({ isEditEventPage: state.viewEventPage.eventDetailsSection && state.viewEventPage.eventDetailsSection.showEditEvent, eventId: state.viewEventPage.eventId, - eventData: state.viewEventPage.loadedValues || {}, programId: state.currentSelections.programId, ...ownProps, }); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts index 337cb058f4..8f675676ad 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts @@ -3,7 +3,6 @@ import { ProgramStage } from 'capture-core/metaData'; export type PlainProps = { isEditEventPage?: boolean; eventId: string; - eventData: any; onOpenEditEvent: (orgUnit: any, programCategory?: any) => void; programStage: ProgramStage; eventAccess: { read: boolean, write: boolean }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx index da9ff11032..ddfed141fe 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.component.tsx @@ -5,7 +5,7 @@ import { dataElementTypes } from '../../../metaData'; import type { Props } from './EventChangelogWrapper.types'; import { WidgetEventChangelog } from '../../WidgetsChangelog'; -export const EventChangelogWrapper = ({ formFoundation, eventId, eventData, ...passOnProps }: Props) => { +export const EventChangelogWrapper = ({ formFoundation, eventId, ...passOnProps }: Props) => { const dataItemDefinitions = useMemo(() => { const elements = formFoundation.getElements(); const contextLabels = formFoundation.getLabels(); @@ -62,7 +62,6 @@ export const EventChangelogWrapper = ({ formFoundation, eventId, eventData, ...p ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts index a5c244f7e9..2ef4d0c471 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EventChangelogWrapper/EventChangelogWrapper.types.ts @@ -8,5 +8,4 @@ type PassOnProps = { export type Props = PassOnProps & { formFoundation: RenderFoundation, - eventData: Record, }; 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 05aa446ea2..7e118bed7c 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -204,7 +204,6 @@ const WidgetEventEditPlain = ({ isOpen setIsOpen={setChangeLogIsOpen} eventId={loadedValues.eventContainer.id} - eventData={loadedValues.eventContainer.values} formFoundation={formFoundation} /> )} diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx index d515fa1892..6b8a1f80f7 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/TrackedEntityChangelogWrapper/TrackedEntityChangelogWrapper.component.tsx @@ -68,7 +68,6 @@ export const TrackedEntityChangelogWrapper = ({ close={() => setIsOpen(false)} programId={programAPI.id} dataItemDefinitions={dataItemDefinitions} - trackedEntityData={transformedTrackedEntityData} /> ); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx index 2b42b3be8c..35f14fe49c 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/WidgetProfile.component.tsx @@ -19,8 +19,7 @@ import { useTeiDisplayName, } from './hooks'; import { DataEntry, dataEntryActionTypes, TEI_MODAL_STATE, convertClientToView } from './DataEntry'; -import { ReactQueryAppNamespace } from '../../utils/reactQueryHelpers'; -import { CHANGELOG_ENTITY_TYPES } from '../WidgetsChangelog'; +import { removeTrackedEntityChangelogQueries } from '../WidgetsChangelog'; import { OverflowMenu } from './OverflowMenu'; import { useDataEntryFormConfig, @@ -143,7 +142,7 @@ const WidgetProfilePlain = ({ }), [clientAttributesWithSubvalues]); const onSaveExternal = useCallback(() => { - queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY, teiId]); + removeTrackedEntityChangelogQueries(queryClient, teiId); }, [queryClient, teiId]); useEffect(() => { diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index 2b0706687a..f3ea155a6b 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -5,11 +5,12 @@ import { MenuItem, IconRedo16, } from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; import { DirectionalArrow } from '../../../../../../../utils/rtl'; type Props = { @@ -28,6 +29,7 @@ export const SkipAction = ({ onUpdateEventStatus, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showError } = useAlert( ({ message }) => message, { critical: true }, @@ -55,6 +57,9 @@ export const SkipAction = ({ return { previousStatus }; }, + onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); + }, onError: (error: unknown, payload: { status: string }, context?: { previousStatus: string }) => { showError({ message: i18n.t('An error occurred when updating event status') }); log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx index 0db8eb304b..2e95903a55 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetEventChangelog/WidgetEventChangelog.tsx @@ -4,7 +4,6 @@ import { Changelog, CHANGELOG_ENTITY_TYPES } from '../common/Changelog'; type Props = { eventId: string; - eventData: Record; dataItemDefinitions: ItemDefinitions; isOpen: boolean; setIsOpen: (value: boolean | ((prev: boolean) => boolean)) => void; @@ -12,7 +11,6 @@ type Props = { export const WidgetEventChangelog = ({ eventId, - eventData, setIsOpen, ...passOnProps }: Props) => ( @@ -20,7 +18,6 @@ export const WidgetEventChangelog = ({ {...passOnProps} close={() => setIsOpen(false)} entityId={eventId} - entityData={eventData} entityType={CHANGELOG_ENTITY_TYPES.EVENT} /> ); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx index 114e0daf26..07b771793d 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx +++ b/src/core_modules/capture-core/components/WidgetsChangelog/WidgetTrackedEntityChangelog/WidgetTrackedEntityChangelog.tsx @@ -8,20 +8,17 @@ type Props = { dataItemDefinitions: ItemDefinitions; isOpen: boolean; close: () => void; - trackedEntityData: Record; }; export const WidgetTrackedEntityChangelog = ({ teiId, programId, close, - trackedEntityData, ...passOnProps }: Props) => ( ; entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; isOpen: boolean; close: () => void; @@ -17,7 +16,6 @@ type Props = { export const Changelog = ({ entityId, - entityData, entityType, programId, isOpen, @@ -49,7 +47,6 @@ export const Changelog = ({ rawRecords, dataItemDefinitions, entityId, - entityData, entityType, programId, sortDirection, diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts index 2cce8aa7d5..4d2d9163ae 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/index.ts @@ -1,2 +1,3 @@ export { useChangelogData } from './useChangelogData'; export { useListDataValues } from './useListDataValues'; +export { useCurrentEntityValues } from './useCurrentEntityValues'; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts new file mode 100644 index 0000000000..f3f6efc19d --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts @@ -0,0 +1,47 @@ +import { useApiDataQuery } from '../../../../utils/reactQueryHelpers'; +import { CHANGELOG_ENTITY_TYPES, QUERY_KEYS_BY_ENTITY_TYPE } from '../Changelog/Changelog.constants'; + +type Props = { + entityId: string; + entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; + programId?: string; +}; + +export type CurrentValues = Record; + +const FIELDS_BY_ENTITY_TYPE = Object.freeze({ + [CHANGELOG_ENTITY_TYPES.EVENT]: 'dataValues[dataElement,value]', + [CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY]: 'attributes[attribute,value]', +}); + +const NO_VALUES: CurrentValues = Object.freeze({}); + +export const useCurrentEntityValues = ({ entityId, entityType, programId }: Props) => { + const { data, isInitialLoading } = useApiDataQuery( + ['changelog', entityType, entityId, 'currentValues', { programId }], + { + resource: `tracker/${QUERY_KEYS_BY_ENTITY_TYPE[entityType]}/${entityId}`, + params: { + program: programId, + fields: FIELDS_BY_ENTITY_TYPE[entityType], + }, + }, + { + enabled: !!entityId, + select: (response: any) => { + const items = entityType === CHANGELOG_ENTITY_TYPES.EVENT + ? response?.dataValues + : response?.attributes; + return (items ?? []).reduce((acc, { dataElement, attribute, value }) => { + acc[dataElement ?? attribute] = value; + return acc; + }, {}); + }, + }, + ); + + return { + currentValues: data ?? NO_VALUES, + isLoading: isInitialLoading, + }; +}; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts index d9606dcc0a..1d0563ec4c 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts @@ -11,12 +11,12 @@ import { convertServerToClient } from '../../../../converters'; import { convert as convertClientToList } from '../../../../converters/clientToList'; import { RECORD_TYPE, subValueGetterByElementType } from '../utils/getSubValueForChangelogData'; import { makeQuerySingleResource } from '../../../../utils/api'; +import { useCurrentEntityValues } from './useCurrentEntityValues'; type Props = { rawRecords: any; dataItemDefinitions: ItemDefinitions; entityId: string; - entityData: any; entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; programId?: string; sortDirection: SortDirection; @@ -28,7 +28,7 @@ const fetchFormattedValues = async ({ rawRecords, dataItemDefinitions, entityId, - entityData, + currentValues, entityType, programId, absoluteApiPath, @@ -93,7 +93,7 @@ const fetchFormattedValues = async ({ change.previousValue ? getValue(change.previousValue, false) : null, getValue( change.currentValue, - entityData?.[change.attribute ?? change.dataElement]?.value === change.currentValue, + currentValues[change.attribute ?? change.dataElement] === change.currentValue, ), ]); @@ -123,7 +123,6 @@ export const useListDataValues = ({ rawRecords, dataItemDefinitions, entityId, - entityData, entityType, programId, sortDirection, @@ -131,6 +130,11 @@ export const useListDataValues = ({ pageSize, }: Props) => { const dataEngine = useDataEngine(); + const { currentValues, isLoading: isCurrentValuesLoading } = useCurrentEntityValues({ + entityId, + entityType, + programId, + }); const { baseUrl, apiVersion } = useConfig(); const { fromServerDate } = useTimeZoneConversion(); const absoluteApiPath = buildUrl(baseUrl, `api/${apiVersion}`); @@ -146,7 +150,7 @@ export const useListDataValues = ({ entityType, entityId, 'formattedData', - { sortDirection, page, pageSize, programId, rawRecords }, + { sortDirection, page, pageSize, programId, rawRecords, currentValues }, ]; const { data: processedRecords, isError, isInitialLoading } = useQuery( @@ -155,7 +159,7 @@ export const useListDataValues = ({ rawRecords, dataItemDefinitions, entityId, - entityData, + currentValues, entityType, programId, absoluteApiPath, @@ -163,12 +167,12 @@ export const useListDataValues = ({ fromServerDate, }), { - enabled: !!rawRecords && !!dataItemDefinitions && !!entityId && !!entityType, + enabled: !!rawRecords && !!dataItemDefinitions && !!entityId && !!entityType && !isCurrentValuesLoading, keepPreviousData: true, staleTime: Infinity, cacheTime: Infinity, }, ); - return { processedRecords, isLoading: isInitialLoading, isError }; + return { processedRecords, isLoading: isInitialLoading || isCurrentValuesLoading, isError }; }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts new file mode 100644 index 0000000000..0f8e1bba09 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts @@ -0,0 +1,15 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; +import { CHANGELOG_ENTITY_TYPES } from '../Changelog/Changelog.constants'; + +const removeChangelogQueries = ( + queryClient: QueryClient, + entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES], + entityId: string, +) => queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', entityType, entityId]); + +export const removeEventChangelogQueries = (queryClient: QueryClient, eventId: string) => + removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.EVENT, eventId); + +export const removeTrackedEntityChangelogQueries = (queryClient: QueryClient, teiId: string) => + removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.TRACKED_ENTITY, teiId); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/index.ts b/src/core_modules/capture-core/components/WidgetsChangelog/index.ts index eb2bbde779..8d7cf09691 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/index.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/index.ts @@ -1,3 +1,7 @@ export { CHANGELOG_ENTITY_TYPES } from './common/Changelog'; export { WidgetEventChangelog } from './WidgetEventChangelog'; export { WidgetTrackedEntityChangelog } from './WidgetTrackedEntityChangelog'; +export { + removeEventChangelogQueries, + removeTrackedEntityChangelogQueries, +} from './common/utils/removeChangelogQueries'; From ef3f2ed637d8a6f65272c7b1149b9530f7f29f26 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:37:01 +0000 Subject: [PATCH 06/41] fix: (review) devin comments temp --- i18n/en.pot | 4 +- .../MenuItems/CompletionMenuItem.tsx | 2 +- .../EventRow/SkipAction/SkipAction.tsx | 2 +- .../common/Changelog/Changelog.container.tsx | 5 ++ .../common/hooks/useChangelogData.ts | 4 +- .../common/hooks/useCurrentEntityValues.ts | 3 +- .../common/hooks/useListDataValues.ts | 51 ++++++++++++++++--- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 51018224b4..21e9b1aa34 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-05T12:14:30.174Z\n" -"PO-Revision-Date: 2026-08-05T12:14:30.174Z\n" +"POT-Creation-Date: 2026-08-05T14:37:03.184Z\n" +"PO-Revision-Date: 2026-08-05T14:37:03.184Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 73267c2419..47403d8780 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -6,7 +6,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { removeEventChangelogQueries } from '../../WidgetsChangelog'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog/common/utils/removeChangelogQueries'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index f3ea155a6b..ccebf19802 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -10,7 +10,7 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog/common/utils/removeChangelogQueries'; import { DirectionalArrow } from '../../../../../../../utils/rtl'; type Props = { diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/Changelog/Changelog.container.tsx b/src/core_modules/capture-core/components/WidgetsChangelog/common/Changelog/Changelog.container.tsx index ee9eb96194..bed1dce290 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/Changelog/Changelog.container.tsx +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/Changelog/Changelog.container.tsx @@ -36,8 +36,10 @@ export const Changelog = ({ setSortDirection, filterValue, setFilterValue, + filterParam, attributeToFilterBy, setAttributeToFilterBy, + dataUpdatedAt: rawDataUpdatedAt, } = useChangelogData({ entityId, entityType, programId }); const { @@ -50,8 +52,11 @@ export const Changelog = ({ entityType, programId, sortDirection, + columnToSortBy, + filterParam, page, pageSize, + rawDataUpdatedAt, }); const loading = (isChangelogLoading || isProcessingLoading); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.ts index 85e8797145..76cfb6556d 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useChangelogData.ts @@ -40,7 +40,7 @@ export const useChangelogData = ({ entityId, entityType, programId }: Props) => ? undefined : `${columnToSortBy}:${sortDirection}`; - const { data, isInitialLoading, isError } = useApiDataQuery( + const { data, isInitialLoading, isError, dataUpdatedAt } = useApiDataQuery( [ 'changelog', entityType, @@ -74,9 +74,11 @@ export const useChangelogData = ({ entityId, entityType, programId }: Props) => setAttributeToFilterBy, filterValue, setFilterValue, + filterParam, page, pageSize, isLoading: isInitialLoading, isError, + dataUpdatedAt, }; }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts index f3f6efc19d..c96e527baf 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts @@ -17,7 +17,7 @@ const FIELDS_BY_ENTITY_TYPE = Object.freeze({ const NO_VALUES: CurrentValues = Object.freeze({}); export const useCurrentEntityValues = ({ entityId, entityType, programId }: Props) => { - const { data, isInitialLoading } = useApiDataQuery( + const { data, isInitialLoading, dataUpdatedAt } = useApiDataQuery( ['changelog', entityType, entityId, 'currentValues', { programId }], { resource: `tracker/${QUERY_KEYS_BY_ENTITY_TYPE[entityType]}/${entityId}`, @@ -43,5 +43,6 @@ export const useCurrentEntityValues = ({ entityId, entityType, programId }: Prop return { currentValues: data ?? NO_VALUES, isLoading: isInitialLoading, + dataUpdatedAt, }; }; diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts index 1d0563ec4c..de53f7c8dc 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts @@ -20,8 +20,11 @@ type Props = { entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; programId?: string; sortDirection: SortDirection; + columnToSortBy: string; + filterParam?: string; page: number; pageSize: number; + rawDataUpdatedAt: number; }; const fetchFormattedValues = async ({ @@ -37,9 +40,12 @@ const fetchFormattedValues = async ({ }) => { if (!rawRecords) return []; + const getFieldId = (change: Change) => change.dataElement ?? change.attribute ?? change.field; + + const getChange = (changelog: any): Change => changelog.change[Object.keys(changelog.change)[0]]; + const getItemDefinition = (change: Change) => { - const { dataElement, attribute, field } = change; - const fieldId = dataElement ?? attribute ?? field; + const fieldId = getFieldId(change); if (!fieldId) { log.error('Could not find fieldId in change:', change); return null; @@ -47,6 +53,19 @@ const fetchFormattedValues = async ({ return dataItemDefinitions[fieldId]; }; + const newestChangeAtByFieldId = rawRecords.changeLogs.reduce( + (acc: Record, changelog: any) => { + const fieldId = getFieldId(getChange(changelog)); + if (!fieldId) return acc; + const newestSoFar = acc[fieldId]; + if (!newestSoFar || new Date(changelog.createdAt) > new Date(newestSoFar)) { + acc[fieldId] = changelog.createdAt; + } + return acc; + }, + {} as Record, + ); + const results = await Promise.all( rawRecords.changeLogs.map(async (changelog) => { const { change: apiChange, createdAt, createdBy, type } = changelog; @@ -89,12 +108,12 @@ const fetchFormattedValues = async ({ return null; }; + const isLatestValue = newestChangeAtByFieldId[metadataElement.id] === createdAt && + currentValues[metadataElement.id] === change.currentValue; + const [previousValueClient, currentValueClient] = await Promise.all([ change.previousValue ? getValue(change.previousValue, false) : null, - getValue( - change.currentValue, - currentValues[change.attribute ?? change.dataElement] === change.currentValue, - ), + getValue(change.currentValue, isLatestValue), ]); const { firstName, surname, username } = createdBy; @@ -126,11 +145,18 @@ export const useListDataValues = ({ entityType, programId, sortDirection, + columnToSortBy, + filterParam, page, pageSize, + rawDataUpdatedAt, }: Props) => { const dataEngine = useDataEngine(); - const { currentValues, isLoading: isCurrentValuesLoading } = useCurrentEntityValues({ + const { + currentValues, + isLoading: isCurrentValuesLoading, + dataUpdatedAt: currentValuesUpdatedAt, + } = useCurrentEntityValues({ entityId, entityType, programId, @@ -150,7 +176,16 @@ export const useListDataValues = ({ entityType, entityId, 'formattedData', - { sortDirection, page, pageSize, programId, rawRecords, currentValues }, + { + columnToSortBy, + sortDirection, + page, + pageSize, + programId, + filterParam, + rawDataUpdatedAt, + currentValuesUpdatedAt, + }, ]; const { data: processedRecords, isError, isInitialLoading } = useQuery( From d8449fb0707f3ac60a4d9672b3fd6c1890e57462 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:56:18 +0000 Subject: [PATCH 07/41] feat: simplify imports for removeEventChangelogQueries and refactor fetchFormattedValues logic --- i18n/en.pot | 4 ++-- .../MenuItems/CompletionMenuItem.tsx | 2 +- .../EventRow/SkipAction/SkipAction.tsx | 2 +- .../common/hooks/useListDataValues.ts | 22 ++----------------- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 21e9b1aa34..aff59e945d 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-05T14:37:03.184Z\n" -"PO-Revision-Date: 2026-08-05T14:37:03.184Z\n" +"POT-Creation-Date: 2026-08-05T14:56:20.610Z\n" +"PO-Revision-Date: 2026-08-05T14:56:20.610Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 47403d8780..73267c2419 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -6,7 +6,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { removeEventChangelogQueries } from '../../WidgetsChangelog/common/utils/removeChangelogQueries'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index ccebf19802..f3ea155a6b 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -10,7 +10,7 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; -import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog/common/utils/removeChangelogQueries'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; import { DirectionalArrow } from '../../../../../../../utils/rtl'; type Props = { diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts index de53f7c8dc..dbe064f5e6 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts @@ -40,12 +40,8 @@ const fetchFormattedValues = async ({ }) => { if (!rawRecords) return []; - const getFieldId = (change: Change) => change.dataElement ?? change.attribute ?? change.field; - - const getChange = (changelog: any): Change => changelog.change[Object.keys(changelog.change)[0]]; - const getItemDefinition = (change: Change) => { - const fieldId = getFieldId(change); + const fieldId = change.dataElement ?? change.attribute ?? change.field; if (!fieldId) { log.error('Could not find fieldId in change:', change); return null; @@ -53,19 +49,6 @@ const fetchFormattedValues = async ({ return dataItemDefinitions[fieldId]; }; - const newestChangeAtByFieldId = rawRecords.changeLogs.reduce( - (acc: Record, changelog: any) => { - const fieldId = getFieldId(getChange(changelog)); - if (!fieldId) return acc; - const newestSoFar = acc[fieldId]; - if (!newestSoFar || new Date(changelog.createdAt) > new Date(newestSoFar)) { - acc[fieldId] = changelog.createdAt; - } - return acc; - }, - {} as Record, - ); - const results = await Promise.all( rawRecords.changeLogs.map(async (changelog) => { const { change: apiChange, createdAt, createdBy, type } = changelog; @@ -108,8 +91,7 @@ const fetchFormattedValues = async ({ return null; }; - const isLatestValue = newestChangeAtByFieldId[metadataElement.id] === createdAt && - currentValues[metadataElement.id] === change.currentValue; + const isLatestValue = currentValues[metadataElement.id] === change.currentValue; const [previousValueClient, currentValueClient] = await Promise.all([ change.previousValue ? getValue(change.previousValue, false) : null, From 93c464969123b0e6cc763bd14be23453a5c80dc3 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:29:33 +0000 Subject: [PATCH 08/41] fix: clean up --- i18n/en.pot | 4 ++-- .../common/hooks/useCurrentEntityValues.ts | 5 +++-- .../WidgetsChangelog/common/hooks/useListDataValues.ts | 9 +++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index aff59e945d..aa19fbb145 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-05T14:56:20.610Z\n" -"PO-Revision-Date: 2026-08-05T14:56:20.610Z\n" +"POT-Creation-Date: 2026-08-06T07:29:35.617Z\n" +"PO-Revision-Date: 2026-08-06T07:29:35.617Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts index c96e527baf..a45b91b9b5 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useCurrentEntityValues.ts @@ -5,6 +5,7 @@ type Props = { entityId: string; entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; programId?: string; + enabled?: boolean; }; export type CurrentValues = Record; @@ -16,7 +17,7 @@ const FIELDS_BY_ENTITY_TYPE = Object.freeze({ const NO_VALUES: CurrentValues = Object.freeze({}); -export const useCurrentEntityValues = ({ entityId, entityType, programId }: Props) => { +export const useCurrentEntityValues = ({ entityId, entityType, programId, enabled = true }: Props) => { const { data, isInitialLoading, dataUpdatedAt } = useApiDataQuery( ['changelog', entityType, entityId, 'currentValues', { programId }], { @@ -27,7 +28,7 @@ export const useCurrentEntityValues = ({ entityId, entityType, programId }: Prop }, }, { - enabled: !!entityId, + enabled: !!entityId && enabled, select: (response: any) => { const items = entityType === CHANGELOG_ENTITY_TYPES.EVENT ? response?.dataValues diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts index dbe064f5e6..2a8d0da53f 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/hooks/useListDataValues.ts @@ -134,6 +134,14 @@ export const useListDataValues = ({ rawDataUpdatedAt, }: Props) => { const dataEngine = useDataEngine(); + const hasFileOrImageField = useMemo( + () => Object.values(dataItemDefinitions ?? {}).some( + (definition: any) => + definition?.type === dataElementTypes.FILE_RESOURCE || + definition?.type === dataElementTypes.IMAGE, + ), + [dataItemDefinitions], + ); const { currentValues, isLoading: isCurrentValuesLoading, @@ -142,6 +150,7 @@ export const useListDataValues = ({ entityId, entityType, programId, + enabled: hasFileOrImageField, }); const { baseUrl, apiVersion } = useConfig(); const { fromServerDate } = useTimeZoneConversion(); 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 09/41] 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 10/41] 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 11/41] 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 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 23/41] 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 24/41] 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 25/41] 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 26/41] 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 27/41] 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 28/41] 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 e28f271fb1b6c528f500481855a804910be81d65 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:59:31 +0000 Subject: [PATCH 29/41] feat: integrate event changelog query removal in bulk completion actions --- i18n/en.pot | 4 ++-- .../EnrollmentPageDefault.container.tsx | 6 +++++- .../common/utils/removeChangelogQueries.ts | 15 +++++++++++---- .../CompleteAction/hooks/useBulkCompleteEvents.ts | 6 +++++- .../hooks/useCompleteBulkEnrollments.ts | 11 +++++++++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 1209f22055..73b85af54d 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-12T10:46:09.643Z\n" -"PO-Revision-Date: 2026-08-12T10:46:09.643Z\n" +"POT-Creation-Date: 2026-08-13T12:59:32.261Z\n" +"PO-Revision-Date: 2026-08-13T12:59:32.261Z\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/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index bfa61fbf85..0b2af5d2a9 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -5,7 +5,9 @@ import { errorCreator } from 'capture-core-utils'; import { formatMomentEn } from 'capture-core-utils/date'; import { useDispatch, useSelector } from 'react-redux'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; +import { useQueryClient } from '@tanstack/react-query'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; import { commitEnrollmentAndEvents, EnrollmentAccessProvider, @@ -50,6 +52,7 @@ import { useHideWidgetByRuleLocations } from '../../../../hooks'; export const EnrollmentPageDefault = () => { const { navigate } = useNavigate(); const dispatch = useDispatch(); + const queryClient = useQueryClient(); const { fromClientDate } = useTimeZoneConversion(); const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); @@ -181,7 +184,8 @@ export const EnrollmentPageDefault = () => { ); const onUpdateEnrollmentStatusSuccess = useCallback(() => { dispatch(commitEnrollmentAndEvents()); - }, [dispatch]); + removeEventChangelogQueries(queryClient); + }, [dispatch, queryClient]); const onBackToMainPage = useCallback(() => { navigate(`/?${buildUrlQueryString({ orgUnitId, programId })}`); diff --git a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts index 0f8e1bba09..fb7f824ade 100644 --- a/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts +++ b/src/core_modules/capture-core/components/WidgetsChangelog/common/utils/removeChangelogQueries.ts @@ -2,13 +2,20 @@ import type { QueryClient } from '@tanstack/react-query'; import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../Changelog/Changelog.constants'; +type ChangelogEntityType = typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES]; + const removeChangelogQueries = ( queryClient: QueryClient, - entityType: typeof CHANGELOG_ENTITY_TYPES[keyof typeof CHANGELOG_ENTITY_TYPES], - entityId: string, -) => queryClient.removeQueries([ReactQueryAppNamespace, 'changelog', entityType, entityId]); + entityType: ChangelogEntityType, + entityId?: string, +) => { + const queryKey = entityId + ? [ReactQueryAppNamespace, 'changelog', entityType, entityId] + : [ReactQueryAppNamespace, 'changelog', entityType]; + queryClient.removeQueries(queryKey); +}; -export const removeEventChangelogQueries = (queryClient: QueryClient, eventId: string) => +export const removeEventChangelogQueries = (queryClient: QueryClient, eventId?: string) => removeChangelogQueries(queryClient, CHANGELOG_ENTITY_TYPES.EVENT, eventId); export const removeTrackedEntityChangelogQueries = (queryClient: QueryClient, teiId: string) => diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts index 889127fb1b..ea247a53c5 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo } from 'react'; import i18n from '@dhis2/d2-i18n'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; type Props = { @@ -23,6 +24,7 @@ export const useBulkCompleteEvents = ({ programId, }: Props) => { const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); const { show: showAlert } = useAlert( ({ message }) => message, { critical: true }, @@ -86,9 +88,11 @@ export const useBulkCompleteEvents = ({ .find(errorReport => errorReport.uid === eventId), ); + removeEventChangelogQueries(queryClient); removeRowsFromSelection(validEventIds); onUpdateList(true); } else { + removeEventChangelogQueries(queryClient); onUpdateList(); setIsCompleteDialogOpen(false); } diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts index aa7388e5bc..d464d8bc02 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts @@ -7,6 +7,7 @@ import { errorCreator } from 'capture-core-utils'; import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; import type { ProgramStage } from '../../../../../../../metaData'; +import { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; type Props = { selectedRows: Record; @@ -154,7 +155,10 @@ export const useCompleteBulkEnrollments = ({ } = useMutation( ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), { - onSuccess: () => { + onSuccess: (_, { enrollments }: any) => { + if (enrollments.some(e => e.events?.length > 0)) { + removeEventChangelogQueries(queryClient); + } onUpdateList(); removeQueries(); }, @@ -178,7 +182,10 @@ export const useCompleteBulkEnrollments = ({ } = useMutation( ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), { - onSuccess: (serverResponse, { enrollments }) => { + onSuccess: (_, { enrollments }) => { + if (enrollments.some(e => e.events?.length > 0)) { + removeEventChangelogQueries(queryClient); + } const enrollmentIds = enrollments.map(enrollment => enrollment.trackedEntity); removeRowsFromSelection(enrollmentIds); removeQueries(); 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 30/41] 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 039621835d48d2f8edb44897535c2b435f5cb6f2 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:26:47 +0000 Subject: [PATCH 31/41] Merge branch 'hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/chore/DHIS2-21941_SingleSourceChangelogValues --- i18n/en.pot | 19 ++--- .../capture-core-utils/types/api-types.ts | 1 - .../MenuItems/CompletionMenuItem.tsx | 5 +- .../MenuItems/DeleteMenuItem.tsx | 76 ++++--------------- .../EnrollmentEditEventPage.container.tsx | 14 ++-- .../EventDetailsSection.component.tsx | 15 +++- .../ViewEvent.component.tsx | 16 ++-- .../ViewEventReadOnlyBadge.component.tsx | 15 ++-- .../EnrollmentAccessContext.tsx | 37 ++++----- .../EnrollmentReadOnlyBadge.component.tsx | 10 +-- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 20 ++--- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 10 +-- .../Actions/Actions.container.tsx | 4 +- .../EditEventDataEntry.component.tsx | 8 +- .../WidgetEventEdit.container.tsx | 10 ++- .../WidgetHeader/WidgetHeader.container.tsx | 8 +- .../WidgetHeader/WidgetHeader.types.ts | 1 + .../OverflowMenu/OverflowMenu.container.tsx | 5 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 45 +++++++---- .../hooks/useBulkCompleteEvents.ts | 3 +- .../DeleteEnrollmentsAction.tsx | 6 +- .../DeleteTeiAction/DeleteTeiAction.tsx | 7 +- .../hooks/computeCanUncompleteEvent.ts | 22 ++++++ .../hooks/useCanChangeCompletionStatus.ts | 54 ++++++++----- .../hooks/useCompleteEventsExpiryForUser.ts | 4 +- .../hooks/useEventEditPermissions.ts | 68 ++++++++--------- .../hooks/useProgramExpiryForUser.ts | 4 +- .../utils/authority/authorities.ts | 8 ++ .../capture-core/utils/authority/index.ts | 2 + .../utils/authority/useAuthorities.ts | 27 ------- .../utils/authority/useAuthority.ts | 14 ++++ .../utils/userInfo/useAuthority.ts | 25 ------ 32 files changed, 259 insertions(+), 304 deletions(-) create mode 100644 src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts create mode 100644 src/core_modules/capture-core/utils/authority/authorities.ts create mode 100644 src/core_modules/capture-core/utils/authority/index.ts delete mode 100644 src/core_modules/capture-core/utils/authority/useAuthorities.ts create mode 100644 src/core_modules/capture-core/utils/authority/useAuthority.ts delete mode 100644 src/core_modules/capture-core/utils/userInfo/useAuthority.ts diff --git a/i18n/en.pot b/i18n/en.pot index 73b85af54d..fa7b1fcd7f 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-13T12:59:32.261Z\n" -"PO-Revision-Date: 2026-08-13T12:59:32.261Z\n" +"POT-Creation-Date: 2026-08-15T19:26:49.707Z\n" +"PO-Revision-Date: 2026-08-15T19:26:49.707Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -461,15 +461,6 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event has been completed" -msgstr "This event has been completed" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - msgid "Delete" msgstr "Delete" @@ -1128,6 +1119,9 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" +msgid "This event has been completed" +msgstr "This event has been completed" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" @@ -1962,6 +1956,9 @@ msgstr "Program stage name" msgid "Working list could not be loaded" msgstr "Working list could not be loaded" +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + msgid "Download data..." msgstr "Download data..." diff --git a/src/core_modules/capture-core-utils/types/api-types.ts b/src/core_modules/capture-core-utils/types/api-types.ts index 3cecf966fe..fdd2df9961 100644 --- a/src/core_modules/capture-core-utils/types/api-types.ts +++ b/src/core_modules/capture-core-utils/types/api-types.ts @@ -23,7 +23,6 @@ export type ApiEnrollmentEvent = { occurredAt: string; scheduledAt: string; completedAt?: string; - completedBy?: string; updatedAt: string; dataValues: Array; notes?: Array; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 73267c2419..ff314e0ccf 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -42,7 +42,8 @@ export const CompletionMenuItem = ({ resource: 'tracker/events', id: eventId, params: { - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', }, }, }) as any; @@ -79,7 +80,7 @@ export const CompletionMenuItem = ({ dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} icon={isCompleted ? : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix="" + suffix={null} onClick={() => { onClose(); updateCompletionStatus(); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 64041e51b3..b37bfbffca 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -5,75 +5,25 @@ import { IconDelete16, MenuItem, } from '@dhis2/ui'; -import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../../converters'; -import { dataElementTypes, type ProgramStage } from '../../../metaData'; -import { useEventEditPermissions } from '../../../hooks'; type Props = { setActionsOpen: (open: boolean) => void; setDeleteModalOpen: (open: boolean) => void; - occurredAt: string; - completedAt?: string; - eventStatus?: string; - programId: string; - programStage?: ProgramStage | null; }; export const DeleteActionButton = ({ setActionsOpen, setDeleteModalOpen, - occurredAt, - completedAt, - eventStatus, - programId, - programStage, -}: Props) => { - const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; - const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); - - const { - isEventWithinValidPeriod, - canEditCompletedEvent, - readOnly, - } = useEventEditPermissions({ - programId, - stage: programStage, - eventStatus, - occurredAtClient, - completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, - }); - - const getDisabledMessage = (): string => { - if (!isEventWithinValidPeriod) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, - interpolation: { escapeValue: false }, - }); - } - if (!canEditCompletedEvent) { - return i18n.t('This event has been completed'); - } - return i18n.t('This event is outside the edit period'); - }; - - return ( - - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); - }} - suffix="" - /> - - ); -}; +}: Props) => ( + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + setDeleteModalOpen(true); + setActionsOpen(false); + }} + suffix={null} + /> +); diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index ef33dfa520..d096550858 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -49,7 +49,7 @@ import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; import { convertIsoToLocalCalendar } from '../../../utils/converters/date'; import { dataEntryHasChanges } from '../../DataEntry/common/dataEntryHasChanges'; import type { UserFormField } from '../../FormFields/UserField'; -import type { ProgramStage } from '../../../metaData'; +import { getProgramEventAccess, type ProgramStage } from '../../../metaData'; const getEventDate = (event) => { const eventDataConvertValue = convertDateWithTimeForView(event?.occurredAt ?? event?.scheduledAt); @@ -264,11 +264,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); + const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -311,9 +310,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventWithinValidPeriod={isEventWithinValidPeriod} - canEditCompletedEvent={canEditCompletedEvent} - isWithinCompleteEventsExpiry={isWithinCompleteExpiry} + isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByCompletion={isEventBlockedByCompletion} > ({ @@ -72,6 +73,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { ...passOnProps } = props; const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); + const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); @@ -79,7 +81,14 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const { isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: loadedValues?.eventContainer?.event?.status, + occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: loadedValues?.eventContainer?.event?.completedAt, + }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index bbee70a42a..617a20fbb5 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -98,10 +98,9 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, } = useEventEditPermissions({ programId, stage: programStage, @@ -109,7 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly; + const showEditButton = !isEditEventPage && !isEventReadOnly; return (
@@ -123,9 +122,8 @@ export const ViewEventPlain = (props: Props & WithStyles) => { />
@@ -138,7 +136,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> ( ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx index d7b31c649e..2b5792ad9b 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -18,9 +18,8 @@ export type EnrollmentAccessContextValue = { showWidgetBadge: boolean; trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -37,6 +36,8 @@ const fallback: EnrollmentAccessContextValue = { showWidgetBadge: true, trackedEntityInactive: false, canToggleTrackedEntityStatus: false, + isEventBlockedByExpiry: false, + isEventBlockedByCompletion: false, }; const Context = createContext(fallback); @@ -45,9 +46,8 @@ type ProviderProps = { program?: TrackerProgram; currentStageId?: string; trackedEntityInactive?: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; children: React.ReactNode; }; @@ -66,9 +66,8 @@ const computeContextValue = ( program: TrackerProgram, currentStageId: string | undefined, trackedEntityInactive: boolean, - isEventWithinValidPeriod?: boolean, - canEditCompletedEvent?: boolean, - isWithinCompleteEventsExpiry?: boolean, + isEventBlockedByExpiry?: boolean, + isEventBlockedByCompletion?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -101,9 +100,8 @@ const computeContextValue = ( showWidgetBadge: !isEventPage && !allWriteAccessMissing, trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, }; }; @@ -111,9 +109,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive = false, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, children, }: ProviderProps) => { const value = useMemo( @@ -122,9 +119,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, ) : { ...fallback, @@ -142,9 +138,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, ], ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx index bb2e96cedb..86685cfdae 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -12,18 +12,16 @@ export const EnrollmentReadOnlyBadge = () => { anyStageReadAccess, trackedEntityTypeName, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEnrollmentAccessContext(); if (isEventPage) { return ( { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -46,9 +45,8 @@ const getReadOnlyMessage = ({ if (!access.program) return getProgramMessage(); if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); - if (!eventWithinValidPeriod) return getExpiredMessage(); - if (!canEditCompletedEvent) return getCompletedEventMessage(); - if (!withinCompleteEventsExpiry) return getExpiredMessage(); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); return ''; }; @@ -56,9 +54,8 @@ const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, - eventWithinValidPeriod = true, - canEditCompletedEvent = true, - withinCompleteEventsExpiry = true, + isEventBlockedByExpiry = false, + isEventBlockedByCompletion = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -74,9 +71,8 @@ const ReadOnlyBadgePlain = ({ access, trackedEntityName, multipleStages, - eventWithinValidPeriod, - canEditCompletedEvent, - withinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index a7ada9cb08..9c36730417 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -2,9 +2,8 @@ export type Props = { programWriteAccess?: boolean; trackedEntityTypeWriteAccess?: boolean; programStageWriteAccess?: boolean; - eventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - withinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -21,8 +20,7 @@ export type ReadOnlyMessageInput = { access: Access; trackedEntityName: string | undefined; multipleStages: boolean; - eventWithinValidPeriod: boolean; - canEditCompletedEvent: boolean; - withinCompleteEventsExpiry: boolean; + isEventBlockedByExpiry: boolean; + isEventBlockedByCompletion: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx index 833d422dd1..bd8a45f4a4 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx @@ -3,7 +3,7 @@ import { ActionsComponent } from './Actions.component'; import type { Props } from './actions.types'; import { useUpdateEnrollment, useDeleteEnrollment } from '../dataMutation/dataMutation'; import { useUpdateOwnership } from './Transfer/hooks'; -import { useAuthorities } from '../../../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../../../utils/authority'; export const Actions = ({ enrollment = {}, @@ -21,7 +21,7 @@ export const Actions = ({ }: Props) => { const { updateMutation, updateLoading } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError, onSuccess); const { deleteMutation, deleteLoading } = useDeleteEnrollment(onDelete, onError, onSuccess); - const { hasAuthority } = useAuthorities({ authorities: ['F_ENROLLMENT_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const { updateEnrollmentOwnership, isTransferLoading } = useUpdateOwnership({ teiId: enrollment.trackedEntity, programId: enrollment.program, diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index e92bb54063..51720d4f92 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -323,9 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -340,9 +338,7 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return createComponentProps(props, { label: i18n.t('Complete event'), 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 3a1237627f..062d2e3852 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -20,6 +20,7 @@ import { useEnrollmentEditEventPageMode, useAvailableProgramStages, useEventEditPermissions, + useProgramExpiryForUser, } from '../../hooks'; import { convertFormToClient } from '../../converters'; import { dataElementTypes } from '../../metaData'; @@ -108,13 +109,15 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ + const expiryPeriod = useProgramExpiryForUser(programId); + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; return orgUnit && loadedValues ? (
@@ -139,7 +142,8 @@ const WidgetEventEditPlain = ({ programId={programId} orgUnit={orgUnit} setChangeLogIsOpen={setChangeLogIsOpen} - readOnly={readOnly} + readOnly={isEventReadOnly} + canUncompleteEvent={canUncompleteEvent} /> } noncollapsible @@ -176,7 +180,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!readOnly} + hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index e6aee5c4ed..3ed32293ed 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useEnrollmentEditEventPageMode, useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; @@ -45,12 +45,12 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + canUncompleteEvent, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage, eventStatus }); const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; @@ -61,7 +61,7 @@ const WidgetHeaderPlain = ({ const onCompletionStatusMutate = useCallback((newStatus: string) => { if (storedEvent) { - const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; + const { completedAt, ...eventWithoutCompletion } = storedEvent; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); } }, [dispatch, storedEvent, eventId]); @@ -119,7 +119,7 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > - {canChangeCompletionStatus && ( + {canUncompleteEvent && ( void, readOnly: boolean, + canUncompleteEvent: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx index 6fa5973130..ef1263df7f 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; + +import { useAuthority, Authorities } from '../../../utils/authority'; import type { Props } from './OverflowMenu.types'; import { OverflowMenuComponent } from './OverflowMenu.component'; @@ -18,7 +19,7 @@ export const OverflowMenu = ({ programAPI, readOnlyMode, }: Props) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_TEI_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); return ( = { row: { maxWidth: '100%', @@ -32,6 +35,17 @@ const styles: Readonly = { }, }; +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + +const getRowClass = (classes: Record, disabled: boolean) => + (disabled ? classes.rowDisabled : classes.row); + +const isCompletionToggleable = (status: string, blockedByCompletion: boolean, blockedByExpiry: boolean) => + !blockedByCompletion + && !blockedByExpiry + && (status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED); + const EventRowPlain = ({ id, pendingApiResponse, @@ -49,14 +63,21 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, + occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); + const canToggleCompletion = isCompletionToggleable( + eventDetails.status, + isEventBlockedByCompletion, + isEventBlockedByExpiry, + ); const onCompletionStatusMutate = useCallback((newStatus: string) => { - const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; + const { completedAt, ...eventWithoutCompletion } = eventDetails; dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch, eventDetails, id]); @@ -70,7 +91,7 @@ const EventRowPlain = ({ return ( {cells} @@ -78,9 +99,9 @@ const EventRowPlain = ({ {stageWriteAccess && ( <> - {pendingApiResponse ? ( - - ) : ( + {pendingApiResponse && } + + {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( setActionsOpen(prev => !prev)} @@ -93,8 +114,7 @@ const EventRowPlain = ({ dense dataTest={'overflow-menu'} > - {(eventDetails.status === eventStatuses.SCHEDULE || - eventDetails.status === eventStatuses.SKIPPED) && ( + {isSkippableStatus(eventDetails.status) && ( )} - {canChangeCompletionStatus && ( + {canToggleCompletion && ( )} diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts index ea247a53c5..57e41feef4 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts @@ -35,7 +35,8 @@ export const useBulkCompleteEvents = ({ { resource: 'tracker/events', params: () => ({ - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', pageSize: 100, program: programId, events: Object.keys(selectedRows).join(','), diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx index f6d1f5051a..3af7917a1d 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; import type { PlainProps } from './DeleteEnrollmentsAction.types'; @@ -16,8 +16,6 @@ const getTooltipContent = (programDataWriteAccess: boolean, bulkDataEntryIsActiv return ''; }; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_ENROLLMENT_CASCADE_DELETE'; - export const DeleteEnrollmentsAction = ({ selectedRows, programDataWriteAccess, @@ -26,7 +24,7 @@ export const DeleteEnrollmentsAction = ({ bulkDataEntryIsActive, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const tooltipContent = getTooltipContent(programDataWriteAccess, bulkDataEntryIsActive); const disabled = !programDataWriteAccess || bulkDataEntryIsActive; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx index 44daa45d42..f03156f92e 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx @@ -1,13 +1,10 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { useCascadeDeleteTei } from './hooks/useCascadeDeleteTei'; import type { PlainProps } from './DeleteTeiAction.types'; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_TEI_CASCADE_DELETE'; - - // TODO - Add program and TEType access checks before adding action to prod export const DeleteTeiAction = ({ selectedRows, @@ -16,7 +13,7 @@ export const DeleteTeiAction = ({ onUpdateList, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); const { deleteTeis, isLoading } = useCascadeDeleteTei({ selectedRows, setIsDeleteDialogOpen, diff --git a/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts new file mode 100644 index 0000000000..79a9137480 --- /dev/null +++ b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts @@ -0,0 +1,22 @@ +import { statusTypes as eventStatuses } from '../events/statusTypes'; + +export const computeCanUncompleteEvent = ({ + hasWriteAccess, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, +}: { + hasWriteAccess: boolean, + eventStatus: string | undefined, + isExpired: boolean, + hasEditExpiredAuthority: boolean, + hasUncompleteAuthority: boolean, +}): boolean => { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + if (isExpired && !hasEditExpiredAuthority) return false; + return hasUncompleteAuthority; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts index 48f90bf631..e602b64768 100644 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -1,26 +1,42 @@ -import { useAuthorities } from '../utils/authority/useAuthorities'; -import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { statusTypes as eventStatuses } from '../events/statusTypes'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { useProgramExpiryForUser } from './useProgramExpiryForUser'; +import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, +} from '../utils/validation/validators/form'; +import { useAuthority, Authorities } from '../utils/authority'; +import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { - programId: string, - stage?: ProgramStage | null, - eventStatus?: string, + programId: string; + stage?: ProgramStage | null; + eventStatus?: string; + occurredAtClient?: string; + completedAtClient?: string; }; -// canChangeCompletionStatus is true when ALL of the following hold: -// - Write access to the program stage (eventAccess.write is true). -// - Event status is ACTIVE, OR status is COMPLETED and the user has F_UNCOMPLETE_EVENT. - -export const useCanChangeCompletionStatus = ({ programId, stage, eventStatus }: Input): boolean => { - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); +export const useCanChangeCompletionStatus = ({ + programId, + stage, + eventStatus, + occurredAtClient, + completedAtClient, +}: Input): boolean => { const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); + const expiryPeriod = useProgramExpiryForUser(programId); + const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - if (!eventAccess?.write) { - return false; - } - if (eventStatus === eventStatuses.COMPLETED) { - return canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; + return computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, + }); }; diff --git a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts index d1834547ec..ba5d018b1b 100644 --- a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useCompleteEventsExpiryForUser = (programId: string): number | undefined => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); return useMemo(() => { diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index a382695aef..1f013a17be 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,9 +1,13 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; -import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, +} from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; +import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { programId: string, @@ -14,22 +18,11 @@ type Input = { }; type Output = { - eventAccess: { read: boolean, write: boolean } | null, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, - canEditCompletedEvent: boolean, - canUncompleteEvent: boolean, - expiryPeriod: ReturnType, - readOnly: boolean, + isEventBlockedByExpiry: boolean, + isEventBlockedByCompletion: boolean, + isEventReadOnly: boolean, }; -// An event is read-only when ANY of the following is true: -// - No write access to the program stage (eventAccess.write is false). -// - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). -// - The event is completed and past the completeEventsExpiryDays window (overridden by F_EDIT_EXPIRED). -// - The event is completed on a stage with blockEntryForm set (overridden by F_EDIT_EXPIRED). - - export const useEventEditPermissions = ({ programId, stage, @@ -40,29 +33,32 @@ export const useEventEditPermissions = ({ const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; + + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; + + const canUncompleteEvent = computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, + }); - const canEditCompletedEvent = canEditExpired || !( - stage?.blockEntryForm - && eventStatus === eventStatuses.COMPLETED - ); + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; - const readOnly = !eventAccess?.write - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent; + const isEventReadOnly = !eventAccess?.write + || isEventBlockedByExpiry + || isCompletedAndBlockingForm; return { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - canUncompleteEvent, - expiryPeriod, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, }; }; diff --git a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts index cb4becebe2..4a599762ce 100644 --- a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react'; import { serverToClientExpiryPeriod } from '../converters/serverToClientExpiryPeriod'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useProgramExpiryForUser = (programId: string) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); const expiryPeriod = useMemo(() => { diff --git a/src/core_modules/capture-core/utils/authority/authorities.ts b/src/core_modules/capture-core/utils/authority/authorities.ts new file mode 100644 index 0000000000..7ae1b40729 --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/authorities.ts @@ -0,0 +1,8 @@ +export const Authorities = Object.freeze({ + UNCOMPLETE_EVENT: 'F_UNCOMPLETE_EVENT', + EDIT_EXPIRED: 'F_EDIT_EXPIRED', + TEI_CASCADE_DELETE: 'F_TEI_CASCADE_DELETE', + ENROLLMENT_CASCADE_DELETE: 'F_ENROLLMENT_CASCADE_DELETE', +} as const); + +export type Authority = typeof Authorities[keyof typeof Authorities]; diff --git a/src/core_modules/capture-core/utils/authority/index.ts b/src/core_modules/capture-core/utils/authority/index.ts new file mode 100644 index 0000000000..6aaeca5eea --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/index.ts @@ -0,0 +1,2 @@ +export { useAuthority } from './useAuthority'; +export { Authorities, type Authority } from './authorities'; diff --git a/src/core_modules/capture-core/utils/authority/useAuthorities.ts b/src/core_modules/capture-core/utils/authority/useAuthorities.ts deleted file mode 100644 index be6d8ca5e7..0000000000 --- a/src/core_modules/capture-core/utils/authority/useAuthorities.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; - -const auth = Object.freeze({ - ALL: 'ALL', -}); - -export const useAuthorities = ({ authorities }: { authorities: string[] }) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities: userAuthorities }) => - userAuthorities && - authorities.some( - authority => userAuthorities.includes(auth.ALL) || userAuthorities.includes(authority), - ), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; diff --git a/src/core_modules/capture-core/utils/authority/useAuthority.ts b/src/core_modules/capture-core/utils/authority/useAuthority.ts new file mode 100644 index 0000000000..a40c1d5b4e --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/useAuthority.ts @@ -0,0 +1,14 @@ +import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; +import type { Authority } from './authorities'; + +export const useAuthority = (authority: Authority) => { + const { data } = useApiMetadataQuery( + ['authorities'], + { resource: 'me.json', params: { fields: 'authorities' } }, + { + select: ({ authorities: userAuthorities }: { authorities: string[] }) => + userAuthorities?.includes('ALL') || userAuthorities?.includes(authority), + }, + ); + return { hasAuthority: Boolean(data) }; +}; diff --git a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts b/src/core_modules/capture-core/utils/userInfo/useAuthority.ts deleted file mode 100644 index fe1e883117..0000000000 --- a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useApiMetadataQuery } from '../reactQueryHelpers'; - -type Props = { - authority: string; -}; - -export const useAuthority = ({ authority }: Props) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities }: { authorities: string[] }) => - authorities && - authorities.some(apiAuthority => apiAuthority === 'ALL' || apiAuthority === authority), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; From 2cc3bcc68490d5f2276cef578280086f6a6371c5 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:50:20 +0000 Subject: [PATCH 32/41] Merge remote-tracking branch 'origin/hv/feat/DHIS2-21655_uncomplete-event-view-mode' into hv/chore/DHIS2-21941_SingleSourceChangelogValues --- CHANGELOG.md | 7 +++++++ i18n/en.pot | 4 ++-- package.json | 4 ++-- packages/rules-engine/package.json | 2 +- .../Filters/Contents/withMinCharsToSearchValidation.tsx | 4 +++- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35cc004092..8d9fbdb46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [107.0.1](https://github.com/dhis2/capture-app/compare/v107.0.0...v107.0.1) (2026-08-12) + + +### Bug Fixes + +* [DHIS2-21685] show min characters filter validation only on update attempt ([#4647](https://github.com/dhis2/capture-app/issues/4647)) ([c68ab6f](https://github.com/dhis2/capture-app/commit/c68ab6f8e80f3214f7792230cc060c08a646ed5b)) + # [107.0.0](https://github.com/dhis2/capture-app/compare/v106.7.8...v107.0.0) (2026-08-11) diff --git a/i18n/en.pot b/i18n/en.pot index fa7b1fcd7f..271b7f1c2e 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-15T19:26:49.707Z\n" -"PO-Revision-Date: 2026-08-15T19:26:49.707Z\n" +"POT-Creation-Date: 2026-08-17T07:50:22.336Z\n" +"PO-Revision-Date: 2026-08-17T07:50:22.337Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/package.json b/package.json index 515c7b1bf9..89ae298c99 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "capture-app", "homepage": ".", - "version": "107.0.0", + "version": "107.0.1", "cacheVersion": "2", "license": "BSD-3-Clause", "private": true, @@ -15,7 +15,7 @@ "@dhis2/d2-ui-rich-text": "^7.4.0", "@dhis2/data-engine": "^3.17.3", "@dhis2/rule-engine": "^3.8.2", - "@dhis2/rules-engine-javascript": "107.0.0", + "@dhis2/rules-engine-javascript": "107.0.1", "@dhis2/ui": "^10.17.0", "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", diff --git a/packages/rules-engine/package.json b/packages/rules-engine/package.json index 5fd548a109..bb07127a8d 100644 --- a/packages/rules-engine/package.json +++ b/packages/rules-engine/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/rules-engine-javascript", - "version": "107.0.0", + "version": "107.0.1", "license": "BSD-3-Clause", "main": "./build/cjs/index.js", "scripts": { diff --git a/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx b/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx index a8da9a0b9d..2753dc56ba 100644 --- a/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx +++ b/src/core_modules/capture-core/components/ListView/Filters/Contents/withMinCharsToSearchValidation.tsx @@ -85,9 +85,11 @@ export const withMinCharsToSearchValidation = () => (InnerComponent: React.Compo const WithMinCharsToSearchValidation = (props: any) => { const { filterTypeRef, minCharactersToSearch, handleCommitValue, classes, type, ...rest } = props; const committedValueRef = useRef(undefined); + const errorsVisibleRef = useRef(false); const [committedValue, setCommittedValue] = useState(undefined); const showValidationErrors = useCallback(() => { + errorsVisibleRef.current = true; setCommittedValue(committedValueRef.current); }, []); @@ -110,7 +112,7 @@ export const withMinCharsToSearchValidation = () => (InnerComponent: React.Compo const wrappedHandleCommitValue = useCallback( (value?: Value, isBlur?: boolean) => { committedValueRef.current = value; - if (isBlur) { + if (errorsVisibleRef.current) { setCommittedValue(value); } handleCommitValue?.(value, isBlur); From 3c0b7079620617e41ec45c80d8ee9435fe76eb47 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:54:57 +0000 Subject: [PATCH 33/41] feat: clean up --- CHANGELOG.md | 1 - i18n/en.pot | 4 +- .../WidgetEventEdit.container.tsx | 2 +- .../EventRow/SkipAction/SkipAction.tsx | 4 +- src/core_modules/capture-core/hooks/index.ts | 1 - .../hooks/useCanChangeCompletionStatus.ts | 42 ------------------- 6 files changed, 5 insertions(+), 49 deletions(-) delete mode 100644 src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aaeb567792..d472a5f1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ * **translations:** sync translations from transifex (master) ([#4701](https://github.com/dhis2/capture-app/issues/4701)) ([6b89633](https://github.com/dhis2/capture-app/commit/6b896332c76e75c531250eab227f2460e94442c1)) - ## [107.0.1](https://github.com/dhis2/capture-app/compare/v107.0.0...v107.0.1) (2026-08-12) diff --git a/i18n/en.pot b/i18n/en.pot index b670631b00..b16b67324c 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-17T08:09:50.165Z\n" -"PO-Revision-Date: 2026-08-17T08:09:50.165Z\n" +"POT-Creation-Date: 2026-08-17T12:54:58.831Z\n" +"PO-Revision-Date: 2026-08-17T12:54:58.831Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." 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 062d2e3852..244043ddcf 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -102,7 +102,7 @@ const WidgetEventEditPlain = ({ const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); // "Edit event"-button depends on loadedValues. Delay rendering component until loadedValues has been initialized. - const loadedValues = useSelector((state: { viewEventPage: { loadedValues: any } }) => state.viewEventPage.loadedValues); + const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); const orgUnit = loadedValues?.orgUnit; const occurredAt = loadedValues?.dataEntryValues?.occurredAt; const completedAt = loadedValues?.eventContainer?.event?.completedAt; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index f3ea155a6b..c769d62db2 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -80,7 +80,7 @@ export const SkipAction = ({ icon={} label={i18n.t('Unskip')} onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} - suffix="" + suffix={null} /> ); } @@ -91,7 +91,7 @@ export const SkipAction = ({ icon={} label={i18n.t('Skip')} onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} - suffix="" + suffix={null} /> ); }; diff --git a/src/core_modules/capture-core/hooks/index.ts b/src/core_modules/capture-core/hooks/index.ts index 1c5cdec17c..e9beab6052 100644 --- a/src/core_modules/capture-core/hooks/index.ts +++ b/src/core_modules/capture-core/hooks/index.ts @@ -9,4 +9,3 @@ export { useProgramExpiryForUser } from './useProgramExpiryForUser'; export { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; export { useHideWidgetByRuleLocations } from './useHideWidgetByRuleLocations'; export { useEventEditPermissions } from './useEventEditPermissions'; -export { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts deleted file mode 100644 index e602b64768..0000000000 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getProgramEventAccess, type ProgramStage } from '../metaData'; -import { useProgramExpiryForUser } from './useProgramExpiryForUser'; -import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; -import { - isValidPeriod, - isWithinCompleteEventsExpiry, -} from '../utils/validation/validators/form'; -import { useAuthority, Authorities } from '../utils/authority'; -import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; - -type Input = { - programId: string; - stage?: ProgramStage | null; - eventStatus?: string; - occurredAtClient?: string; - completedAtClient?: string; -}; - -export const useCanChangeCompletionStatus = ({ - programId, - stage, - eventStatus, - occurredAtClient, - completedAtClient, -}: Input): boolean => { - const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); - const expiryPeriod = useProgramExpiryForUser(programId); - const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); - const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); - const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); - const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - - return computeCanUncompleteEvent({ - hasWriteAccess: !!eventAccess?.write, - eventStatus, - isExpired, - hasEditExpiredAuthority, - hasUncompleteAuthority, - }); -}; From 87aab244b497a00a189f8855454f7ed1a11e6f33 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:02:10 +0000 Subject: [PATCH 34/41] fix: allign type annotations for useSelector in EventDetailsSection --- i18n/en.pot | 4 ++-- .../EventDetailsSection/EventDetailsSection.component.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 5c5ff985db..8cb9dee7eb 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-17T12:56:06.473Z\n" -"PO-Revision-Date: 2026-08-17T12:56:06.473Z\n" +"POT-Creation-Date: 2026-08-17T13:02:11.719Z\n" +"PO-Revision-Date: 2026-08-17T13:02:11.719Z\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/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 3b7d989be0..ead322b42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -72,8 +72,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { showEditButton, ...passOnProps } = props; - const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); - const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); + const orgUnitId = useSelector((state: { viewEventPage: { loadedValues: any } }) => + state.viewEventPage.loadedValues?.orgUnit?.id); + const loadedValues = useSelector((state: { viewEventPage: { loadedValues: any } }) => state.viewEventPage.loadedValues); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); 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 35/41] 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 36/41] 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 37/41] 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 38/41] 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 39/41] 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 8a222e2b77d0adea88befa35c5ff6752f7ed61bc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:32 +0000 Subject: [PATCH 40/41] Revert "Merge remote-tracking branch 'origin/hv/chore/DHIS2-21969_refine-configurable-terminology-support' into hv/chore/DHIS2-21941_SingleSourceChangelogValues" This reverts commit 3a4a73f1a47b9fb470e6a483d47d5490714eb13e, reversing changes made to fc1dc997ecc8d8de7593980752f109cf037e8c4c. --- cypress/e2e/ScopeSelector/ScopeSelector.js | 2 +- .../WidgetEnrollmentNote/index.js | 2 +- i18n/en.pot | 99 +++++++-------- .../featuresSupport/support.ts | 2 - .../string/capitalizeFirstLetter.ts | 12 +- .../capture-core/HOC/withCustomLabels.tsx | 26 ---- .../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 +- .../WidgetEnrollment/hooks/useProgram.ts | 27 ++-- .../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 +- .../StageCreateNewButton.tsx | 2 +- .../Stages/Stages.component.tsx | 2 +- .../WidgetStagesAndEvents.component.tsx | 2 +- .../utils/getDataEntryDetails.ts | 68 +++++----- .../TrackedEntityType/TrackedEntityType.ts | 10 ++ .../metaData/helpers/customLabels.ts | 117 ------------------ .../helpers/customLabels/customLabels.ts | 73 +++++++++++ .../metaData/helpers/customLabels/index.ts | 10 ++ .../metaData/helpers/customLabels/useLabel.ts | 45 +++++++ .../capture-core/metaData/helpers/index.ts | 11 +- .../capture-core/metaData/index.ts | 11 +- .../programStage/ProgramStageFactory.ts | 12 +- .../TrackedEntityTypeFactory.ts | 6 +- .../quickStoreOperations/storePrograms.ts | 40 ++---- .../storeTrackedEntityTypes.ts | 5 +- .../trackedEntityInstances/getDisplayName.ts | 4 +- .../capture-core/utils/tCustomTerm.ts | 37 ------ 45 files changed, 311 insertions(+), 400 deletions(-) delete mode 100644 src/core_modules/capture-core/HOC/withCustomLabels.tsx delete mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/index.ts create mode 100644 src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts delete mode 100644 src/core_modules/capture-core/utils/tCustomTerm.ts 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/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 31c6d1654e..991f100a44 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:34:08.034Z\n" -"PO-Revision-Date: 2026-09-01T10:34:08.034Z\n" +"POT-Creation-Date: 2026-08-28T11:16:56.550Z\n" +"PO-Revision-Date: 2026-08-28T11:16:56.550Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -712,8 +712,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" @@ -835,8 +835,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" @@ -856,14 +856,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." @@ -1330,22 +1330,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" @@ -1356,6 +1343,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" @@ -1467,6 +1467,9 @@ 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" @@ -1485,6 +1488,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." @@ -1494,8 +1500,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" @@ -1512,8 +1518,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 "Are you sure you want to delete this event? " msgstr "Are you sure you want to delete this event? " @@ -1599,6 +1605,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" @@ -1757,8 +1766,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" @@ -1789,11 +1798,11 @@ 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 "Program stages and events" -msgstr "Program stages and events" +msgid "Stages and Events" +msgstr "Stages and Events" msgid "An error occurred while unlinking and deleting the event." msgstr "An error occurred while unlinking and deleting the event." @@ -2236,30 +2245,6 @@ 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 90274517ae..baeb663dae 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.ts +++ b/src/core_modules/capture-core-utils/featuresSupport/support.ts @@ -6,7 +6,6 @@ export const FEATURES = Object.freeze({ orgUnitReplaceOuQueryParam: 'orgUnitReplaceOuQueryParam', enrollmentStatusReplaceProgramStatusQueryParam: 'enrollmentStatusReplaceProgramStatusQueryParam', emptyValueFilter: 'emptyValueFilter', - customTerminologyPlurals: 'customTerminologyPlurals', }); const MINOR_VERSION_SUPPORT = Object.freeze({ @@ -17,7 +16,6 @@ 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 fdbe04456c..0184db6f88 100644 --- a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts +++ b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts @@ -1,11 +1,5 @@ -import i18n from '@dhis2/d2-i18n'; - export function capitalizeFirstLetter(text: string) { - 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); - } + const first = text.charAt(0).toLocaleUpperCase(); + const rest = text.slice(1); + return first + rest; } diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx deleted file mode 100644 index 659b0957ad..0000000000 --- a/src/core_modules/capture-core/HOC/withCustomLabels.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/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 43f61a5a59..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 { 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 { tCustomTerm } from '../../utils/tCustomTerm'; +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 && (
- {tCustomTerm( - '{{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 e97d05e6b1..7b0854dce2 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/WidgetEnrollment/hooks/useProgram.ts b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts index b76d2e87ce..048ac5018c 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -1,26 +1,11 @@ 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', -]; - export const useProgram = (programId: string) => { const { error, loading, data } = useDataQuery( useMemo( @@ -28,9 +13,15 @@ export const useProgram = (programId: string) => { program: { resource: `programs/${programId}`, params: { - fields: featureAvailable(FEATURES.customTerminologyPlurals) - ? [...baseFields, ...pluralFields] - : baseFields, + 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', + ], }, }, }), 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/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 13a96518fc..6a410d71ff 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('Stages and Events')} {showWidgetBadge && (
{ - 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/metaData/TrackedEntityType/TrackedEntityType.ts b/src/core_modules/capture-core/metaData/TrackedEntityType/TrackedEntityType.ts index c037bb2ad1..8457d90378 100644 --- a/src/core_modules/capture-core/metaData/TrackedEntityType/TrackedEntityType.ts +++ b/src/core_modules/capture-core/metaData/TrackedEntityType/TrackedEntityType.ts @@ -6,6 +6,7 @@ import type { SearchGroup } from '../SearchGroup'; import type { DataElement } from '../DataElement'; import type { TeiRegistration } from './TeiRegistration'; import type { Access } from '../Access'; +import type { CustomLabels } from '../helpers/customLabels'; export class TrackedEntityType { _id!: string; @@ -14,9 +15,11 @@ export class TrackedEntityType { _teiRegistration!: TeiRegistration; _attributes!: Array; _searchGroups!: Array; + _customLabels!: CustomLabels; constructor(initFn: ((_this: TrackedEntityType) => void) | null) { this._attributes = []; + this._customLabels = {}; initFn && isFunction(initFn) && initFn(this); } @@ -61,4 +64,11 @@ 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/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts deleted file mode 100644 index caf195f551..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels.ts +++ /dev/null @@ -1,117 +0,0 @@ -import i18n from '@dhis2/d2-i18n'; -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { programCollection } from '../../metaDataMemoryStores'; - -type LabelConfig = { - field: string; - pluralField?: string; - singular: () => string; - plural?: () => string; -}; - -const asLabels = (labels: Record) => labels; - -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 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; - -export const resolveLabel = ( - sources: LabelSource | Array, - key: CustomLabelKey, - { plural = false }: LabelOptions = {}, -): string | undefined => { - 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]; -}; - -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 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(); -}; - -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 new file mode 100644 index 0000000000..18938840cd --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000000..49b34132fe --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000000..c733c2e662 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts @@ -0,0 +1,45 @@ +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 b4158ea1a9..627adbd3b3 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -18,9 +18,14 @@ export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { - extractCustomLabels, + CUSTOM_LABEL_FIELDS, resolveLabel, - getTermLabel, - useTermLabel, + extractCustomLabels, + getProgramLabel, + getStageLabel, + getTrackedEntityTypeLabel, + useProgramLabel, + useStageLabel, + useTrackedEntityTypeLabel, } 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 1e8e7461b6..00e7aca7aa 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -40,9 +40,14 @@ export { getProgramThrowIfNotFound, getProgramAndStageForEventProgram, getEventProgramEventAccess, - extractCustomLabels, + CUSTOM_LABEL_FIELDS, resolveLabel, - getTermLabel, - useTermLabel, + extractCustomLabels, + getProgramLabel, + getStageLabel, + getTrackedEntityTypeLabel, + useProgramLabel, + useStageLabel, + useTrackedEntityTypeLabel, } 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 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); 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 fb8c442ede..5513dce0ee 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,5 +1,8 @@ /* eslint-disable no-underscore-dangle */ -import { TrackedEntityType } from '../../../../metaData'; +import { + TrackedEntityType, + extractCustomLabels, +} from '../../../../metaData'; import { DataElementFactory } from './DataElementFactory'; import { TeiRegistrationFactory } from './TeiRegistrationFactory'; import { SearchGroupFactory } from '../../../common/factory'; @@ -81,6 +84,7 @@ 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 7d972501b6..929645433c 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -1,4 +1,3 @@ -import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; import { quickStore } from '../../IOUtils'; import { getContext } from '../../context'; import type { CachedProgramStageDataElement } from '../../../storageControllers'; @@ -98,7 +97,7 @@ const programTrackedEntityAttributeFields = [ 'allowFutureDate', ].join(','); -const baseProgramStageFields = [ +const programStageFields = [ 'id', 'access', 'autoGenerateEvent', @@ -118,6 +117,7 @@ const baseProgramStageFields = [ 'displayDueDateLabel', 'displayProgramStageLabel', 'displayEventLabel', + 'displayEventsLabel', 'formType', 'featureType', 'validationStrategy', @@ -126,13 +126,9 @@ const baseProgramStageFields = [ 'dataEntryForm[id,htmlCode]', 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]]', `programStageDataElements[${programStageDataElementFields}]`, -]; - -const pluralProgramStageFields = [ - 'displayEventsLabel', -]; +].join(','); -const baseProgramFields = [ +const fieldsParam = [ 'id', 'displayName', 'displayShortName', @@ -142,13 +138,16 @@ const baseProgramFields = [ 'displayIncidentDateLabel', 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', + 'displayEnrollmentsLabel', 'displayFollowUpLabel', 'displayOrgUnitLabel', 'displayRelationshipLabel', 'displayNoteLabel', 'displayTrackedEntityAttributeLabel', 'displayProgramStageLabel', + 'displayProgramStagesLabel', 'displayEventLabel', + 'displayEventsLabel', 'minAttributesRequiredToSearch', 'useFirstStageDuringRegistration', 'onlyEnrollOnce', @@ -164,35 +163,16 @@ const baseProgramFields = [ 'access[data[read,write]]', 'trackedEntityType[id]', 'categoryCombo[id,displayName,isDefault,categories[id,displayName]]', + `programStages[${programStageFields}]`, 'programSections[id, displayDescription, displayFormName, sortOrder, trackedEntityAttributes]', `programTrackedEntityAttributes[${programTrackedEntityAttributeFields}]`, -]; - -const pluralProgramFields = [ - 'displayEnrollmentsLabel', - '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(','); -}; +].join(','); export const storePrograms = (programIds: Array) => { - const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const query = { resource: 'programs', params: { - fields: buildFieldsParam(includePluralLabels), + fields: fieldsParam, 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 4271797227..1e6f04c161 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -26,8 +26,7 @@ const convert = (() => { })); })(); -const FIELDS = - 'id,access,displayName,minAttributesRequiredToSearch,featureType,' + +const fieldsParam = 'id,access,displayName,displayTrackedEntityTypesLabel,minAttributesRequiredToSearch,featureType,' + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + 'translations[property,locale,value]'; @@ -35,7 +34,7 @@ export const storeTrackedEntityTypes = (ids: Array) => { const query = { resource: 'trackedEntityTypes', params: { - fields: FIELDS, + fields: fieldsParam, filter: `id:in:[${ids.join(',')}]`, pageSize: ids.length, }, 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) 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 d807b5ef775dd81ec7881c71fb0e5b199ae17edd Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:49:16 +0000 Subject: [PATCH 41/41] fix: remove duplicate import of enrollmentStatuses in CompletionMenuItem --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/MenuItems/CompletionMenuItem.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 991f100a44..903fce47f5 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-28T11:16:56.550Z\n" -"PO-Revision-Date: 2026-08-28T11:16:56.550Z\n" +"POT-Creation-Date: 2026-09-08T15:49:17.652Z\n" +"PO-Revision-Date: 2026-09-08T15:49:17.653Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index 905e4832f9..69899a51e1 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -10,7 +10,6 @@ import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { removeEventChangelogQueries } from '../../WidgetsChangelog'; import { statusTypes as enrollmentStatuses } from '../../../enrollment'; import { CompleteModal } from '../../DataEntries/common/trackerEvent/withAskToCompleteEnrollment/CompleteModal'; -import { statusTypes as enrollmentStatuses } from '../../../enrollment'; import { updateEnrollmentAndEvents, commitEnrollmentAndEvents, @@ -128,6 +127,7 @@ export const CompleteMenuItemModal = ({ }: ModalProps) => { const dataEngine = useDataEngine(); const dispatch = useDispatch(); + const queryClient = useQueryClient(); const { show: showError } = useAlert(({ message }) => message, { critical: true }); const handleError = (error: unknown) => { @@ -140,7 +140,10 @@ export const CompleteMenuItemModal = ({ { onMutate: () => onMutate?.(eventStatuses.COMPLETED), onError: (error) => { handleError(error); onError?.(); }, - onSuccess: () => onSuccess?.(eventStatuses.COMPLETED), + onSuccess: () => { + removeEventChangelogQueries(queryClient, eventId); + onSuccess?.(eventStatuses.COMPLETED); + }, }, ); @@ -171,6 +174,7 @@ export const CompleteMenuItemModal = ({ }, onSuccess: () => { dispatch(commitEnrollmentAndEvents()); + removeEventChangelogQueries(queryClient, eventId); onSuccess?.(eventStatuses.COMPLETED); }, },