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 791a0a9441..c8cadaefb2 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-01T14:32:18.822Z\n" -"PO-Revision-Date: 2026-09-01T14:32:18.822Z\n" +"POT-Creation-Date: 2026-09-09T08:05:05.488Z\n" +"PO-Revision-Date: 2026-09-09T08:05:05.489Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -424,11 +424,11 @@ msgstr "Some operations are still running. Please wait." msgid "Operations running" msgstr "Operations running" -msgid "No feedback for this enrollment yet" -msgstr "No feedback for this enrollment yet" +msgid "No feedback for this {{enrollmentLabel}} yet" +msgstr "No feedback for this {{enrollmentLabel}} yet" -msgid "No indicator output for this enrollment yet" -msgstr "No indicator output for this enrollment yet" +msgid "No indicator output for this {{enrollmentLabel}} yet" +msgstr "No indicator output for this {{enrollmentLabel}} yet" msgid "" "This event has unsaved changes. Leaving this page without saving will lose " @@ -718,6 +718,12 @@ msgstr "Notice" msgid "Close the notice" msgstr "Close the notice" +msgid "No feedback for this enrollment yet" +msgstr "No feedback for this enrollment yet" + +msgid "No indicator output for this enrollment yet" +msgstr "No indicator output for this enrollment yet" + msgid "Quick actions" msgstr "Quick actions" @@ -1449,9 +1455,6 @@ 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" - msgid "Started at{{escape}}" msgstr "Started at{{escape}}" @@ -1533,8 +1536,8 @@ msgstr "Polygon captured" msgid "No polygon captured" msgstr "No polygon captured" -msgid "Event completed" -msgstr "Event completed" +msgid "{{eventLabel}} completed" +msgstr "{{eventLabel}} completed" msgid "Notes about this event" msgstr "Notes about this event" @@ -1807,8 +1810,8 @@ msgstr "{{ scheduledEvents }} scheduled" msgid "No stages found in this program" msgstr "No stages found in this program" -msgid "Stages and Events" -msgstr "Stages and Events" +msgid "{{programStagesLabel}} and {{eventsLabel}}" +msgstr "{{programStagesLabel}} and {{eventsLabel}}" msgid "An error occurred while unlinking and deleting the event." msgstr "An error occurred while unlinking and deleting the event." @@ -2248,6 +2251,30 @@ msgstr "Visited" msgid "{{trackedEntityName}} in program{{escape}} {{programName}}" msgstr "{{trackedEntityName}} in program{{escape}} {{programName}}" +msgid "enrollments" +msgstr "enrollments" + +msgid "program stage" +msgstr "program stage" + +msgid "program stages" +msgstr "program stages" + +msgid "note" +msgstr "note" + +msgid "relationship" +msgstr "relationship" + +msgid "attribute" +msgstr "attribute" + +msgid "organisation unit" +msgstr "organisation unit" + +msgid "follow-up" +msgstr "follow-up" + msgid "Program not found" msgstr "Program not found" diff --git a/src/core_modules/capture-core-utils/featuresSupport/support.ts b/src/core_modules/capture-core-utils/featuresSupport/support.ts index baeb663dae..90274517ae 100644 --- a/src/core_modules/capture-core-utils/featuresSupport/support.ts +++ b/src/core_modules/capture-core-utils/featuresSupport/support.ts @@ -6,6 +6,7 @@ export const FEATURES = Object.freeze({ orgUnitReplaceOuQueryParam: 'orgUnitReplaceOuQueryParam', enrollmentStatusReplaceProgramStatusQueryParam: 'enrollmentStatusReplaceProgramStatusQueryParam', emptyValueFilter: 'emptyValueFilter', + customTerminologyPlurals: 'customTerminologyPlurals', }); const MINOR_VERSION_SUPPORT = Object.freeze({ @@ -16,6 +17,7 @@ const MINOR_VERSION_SUPPORT = Object.freeze({ [FEATURES.orgUnitReplaceOuQueryParam]: 42, [FEATURES.enrollmentStatusReplaceProgramStatusQueryParam]: 42, [FEATURES.emptyValueFilter]: 42, + [FEATURES.customTerminologyPlurals]: 43, }); export const hasAPISupportForFeature = (minorVersion: string | number, featureName: string) => diff --git a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts index 0184db6f88..fdbe04456c 100644 --- a/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts +++ b/src/core_modules/capture-core-utils/string/capitalizeFirstLetter.ts @@ -1,5 +1,11 @@ +import i18n from '@dhis2/d2-i18n'; + export function capitalizeFirstLetter(text: string) { - const first = text.charAt(0).toLocaleUpperCase(); - const rest = text.slice(1); - return first + rest; + if (!text) return text; + const locale = (i18n as any).language ?? 'en'; + try { + return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1); + } catch { + return text.charAt(0).toUpperCase() + text.slice(1); + } } diff --git a/src/core_modules/capture-core/HOC/withCustomLabels.tsx b/src/core_modules/capture-core/HOC/withCustomLabels.tsx new file mode 100644 index 0000000000..d8be251e39 --- /dev/null +++ b/src/core_modules/capture-core/HOC/withCustomLabels.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { useTermLabel, type TermRequest } from '../metaData'; + +export const withCustomLabels = + (requests: ReadonlyArray) => + (InnerComponent: React.ComponentType) => + (props: any) => { + const { programId, stageId } = props; + const labels = useTermLabel(requests, { programId, stageId }); + return ; + }; diff --git a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts index bca310bb95..9459591de7 100644 --- a/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts +++ b/src/core_modules/capture-core/components/DataEntryWidgetOutput/DataEntryWidgetOutput.container.ts @@ -4,6 +4,7 @@ import i18n from '@dhis2/d2-i18n'; import { DataEntryWidgetOutputComponent } from './DataEntryWidgetOutput.component'; import { getDataEntryKey } from '../DataEntry/common/getDataEntryKey'; import { makeProgramRulesSelector } from './DataEntryWidgetOutput.selectors'; +import { getTermLabel, LabelKeys } from '../../metaData'; type OwnProps = { dataEntryId: string; @@ -17,18 +18,26 @@ const makeMapStateToProps = () => { const { dataEntries } = state; const ready = !!dataEntries[dataEntryId]; const dataEntryKey = ready ? getDataEntryKey(dataEntryId, state.dataEntries[dataEntryId].itemId) : null; + // Example use of getTermLabel. + const { enrollmentLabel } = getTermLabel([LabelKeys.enrollmentSingular], { programId: selectedScopeId }); return { ready, dataEntryKey, programRules: programRulesSelector(state, { dataEntryId, selectedScopeId }), - feedbackEmptyText: i18n.t('No feedback for this enrollment yet'), - indicatorEmptyText: i18n.t('No indicator output for this enrollment yet'), + feedbackEmptyText: i18n.t( + 'No feedback for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), + indicatorEmptyText: i18n.t( + 'No indicator output for this {{enrollmentLabel}} yet', + { enrollmentLabel }, + ), }; }; }; export const DataEntryWidgetOutput: ComponentType = - connect(makeMapStateToProps, () => ({}))( - (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), - ); + connect(makeMapStateToProps, () => ({}))( + (props: any) => (props.ready ? React.createElement(DataEntryWidgetOutputComponent, props) : null), + ); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx index 29b02e6023..0f55870ec7 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/WidgetEnrollment.component.tsx @@ -11,13 +11,14 @@ import { import i18n from '@dhis2/d2-i18n'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { capitalizeFirstLetter } from 'capture-core-utils/string'; import { LoadingMaskElementCenter } from '../LoadingMasks'; import { Widget } from '../Widget'; import { ReadOnlyBadge } from '../ReadOnlyBadge'; import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { PlainProps } from './enrollment.types'; import { Status } from './Status'; -import { dataElementTypes } from '../../metaData'; +import { dataElementTypes, getTermLabelFromProgram, LabelKeys } from '../../metaData'; import { convertValue } from '../../converters/clientToView'; import { useOrgUnitNameWithAncestors } from '../../metadataRetrieval/orgUnitName'; import { Date } from './Date'; @@ -94,13 +95,18 @@ const WidgetEnrollmentPlain = ({ const orgUnitClientValue = { id: enrollment?.orgUnit, name: orgUnitName, ancestors }; const ownerOrgUnitClientValue = { id: ownerOrgUnit?.id, name: ownerOrgUnitName, ancestors: ownerAncestors }; + // Example use of getTermLabelFromProgram. + const { enrollmentLabel, followUpLabel } = getTermLabelFromProgram( + [LabelKeys.enrollmentSingular, LabelKeys.followUpSingular], + { program }, + ); return (
- {i18n.t('Enrollment')} + {capitalizeFirstLetter(enrollmentLabel)} {showWidgetBadge && (
{enrollment.followUp && ( - {i18n.t('Follow-up')} + {capitalizeFirstLetter(followUpLabel)} )} diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts index 048ac5018c..bdf71d067a 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/hooks/useProgram.ts @@ -1,11 +1,27 @@ import { useMemo } from 'react'; import { useDataQuery } from '@dhis2/app-runtime'; +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; type ProgramData = { featureType: string; [key: string]: any; }; +const baseFields = [ + 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + + 'displayEnrollmentLabel,displayFollowUpLabel,displayOrgUnitLabel,' + + 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + + 'displayProgramStageLabel,displayEventLabel,' + + 'trackedEntityType[displayName,access],' + + 'programStages[autoGenerateEvent,name,access,id],' + + 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', +]; + +const pluralFields = [ + 'displayEnrollmentsLabel,displayProgramStagesLabel,displayEventsLabel,' + + 'displayNotesLabel,displayRelationshipsLabel,displayTrackedEntityAttributesLabel', +]; + export const useProgram = (programId: string) => { const { error, loading, data } = useDataQuery( useMemo( @@ -13,15 +29,9 @@ export const useProgram = (programId: string) => { program: { resource: `programs/${programId}`, params: { - fields: [ - 'displayIncidentDate,displayIncidentDateLabel,displayEnrollmentDateLabel,onlyEnrollOnce,' + - 'displayEnrollmentLabel,displayEnrollmentsLabel,displayFollowUpLabel,displayOrgUnitLabel,' + - 'displayRelationshipLabel,displayNoteLabel,displayTrackedEntityAttributeLabel,' + - 'displayProgramStageLabel,displayProgramStagesLabel,displayEventLabel,displayEventsLabel,' + - 'trackedEntityType[displayName,access],' + - 'programStages[autoGenerateEvent,name,access,id],' + - 'access,featureType,selectEnrollmentDatesInFuture,selectIncidentDatesInFuture', - ], + fields: featureAvailable(FEATURES.customTerminologyPlurals) + ? [...baseFields, ...pluralFields] + : baseFields, }, }, }), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx index 42edee90ce..a03ba7d136 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.component.tsx @@ -4,6 +4,7 @@ import { withStyles, WithStyles } from 'capture-core-utils/styles'; import { dataEntryIds } from 'capture-core/constants'; import type { ReduxAction } from 'capture-core-utils/types'; import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; import { placements, withCleanUp, @@ -147,7 +148,8 @@ const buildOrgUnitSettingsFn = () => { const orgUnitSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Organisation unit'), + // Example use of withCustomLabels. + label: capitalizeFirstLetter(props.orgUnitLabel), valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), getPropName: () => 'orgUnit', @@ -223,7 +225,8 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => viewModeComponent, getComponentProps: (props: any) => createComponentProps(props, { - label: i18n.t('Event completed'), + // Example use of withCustomLabels. + label: i18n.t('{{eventLabel}} completed', { eventLabel: props.eventLabel }), id: dataElement.id, valueConverter: value => dataElement.convertValue(value, valueConvertFn), }), diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts index c9ecd0d685..41642b64ef 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/ViewEventDataEntry/ViewEventDataEntry.container.ts @@ -1,7 +1,11 @@ import { connect } from 'react-redux'; import { ViewEventDataEntryComponent } from './ViewEventDataEntry.component'; import { withLoadingIndicator } from '../../../HOC/withLoadingIndicator'; +import { withCustomLabels } from '../../../HOC/withCustomLabels'; +import { LabelKeys } from '../../../metaData'; +// Example use of withCustomLabels. +const customLabels = [LabelKeys.orgUnitSingular, LabelKeys.eventSingular] as const; const mapStateToProps = (state: any, props: any) => { const eventDetailsSection = state.viewEventPage.eventDetailsSection || {}; @@ -20,5 +24,5 @@ const mapStateToProps = (state: any, props: any) => { const mapDispatchToProps = (): any => ({}); export const ViewEventDataEntry = connect(mapStateToProps, mapDispatchToProps)( - withLoadingIndicator()(ViewEventDataEntryComponent), + withLoadingIndicator()(withCustomLabels(customLabels)(ViewEventDataEntryComponent)), ); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index f6ab983675..6a4935c7ec 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -152,6 +152,7 @@ const WidgetEventEditPlain = ({ > - {i18n.t('Stages and Events')} + + {i18n.t('{{programStagesLabel}} and {{eventsLabel}}', { + programStagesLabel, + eventsLabel, + })} + {showWidgetBadge && (
; _searchGroups!: Array; - _customLabels!: CustomLabels; constructor(initFn: ((_this: TrackedEntityType) => void) | null) { this._attributes = []; - this._customLabels = {}; initFn && isFunction(initFn) && initFn(this); } @@ -64,11 +61,4 @@ export class TrackedEntityType { get attributes(): Array { return this._attributes; } - - set customLabels(customLabels: CustomLabels) { - this._customLabels = customLabels; - } - get customLabels(): CustomLabels { - return this._customLabels; - } } diff --git a/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts new file mode 100644 index 0000000000..67ead94aa8 --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/constants/customLabels.const.ts @@ -0,0 +1,63 @@ +import i18n from '@dhis2/d2-i18n'; + +export type LabelConfig = { + apiFieldSingular: string; + apiFieldPlural?: string; + defaultSingular: () => string; + defaultPlural?: () => string; +}; + +export const LABELS = { + enrollment: { + apiFieldSingular: 'displayEnrollmentLabel', + apiFieldPlural: 'displayEnrollmentsLabel', + defaultSingular: () => i18n.t('enrollment'), + defaultPlural: () => i18n.t('enrollments'), + }, + event: { + apiFieldSingular: 'displayEventLabel', + apiFieldPlural: 'displayEventsLabel', + defaultSingular: () => i18n.t('event'), + defaultPlural: () => i18n.t('events'), + }, + programStage: { + apiFieldSingular: 'displayProgramStageLabel', + apiFieldPlural: 'displayProgramStagesLabel', + defaultSingular: () => i18n.t('program stage'), + defaultPlural: () => i18n.t('program stages'), + }, + note: { + apiFieldSingular: 'displayNoteLabel', + defaultSingular: () => i18n.t('note'), + }, + relationship: { + apiFieldSingular: 'displayRelationshipLabel', + defaultSingular: () => i18n.t('relationship'), + }, + attribute: { + apiFieldSingular: 'displayTrackedEntityAttributeLabel', + defaultSingular: () => i18n.t('attribute'), + }, + orgUnit: { + apiFieldSingular: 'displayOrgUnitLabel', + defaultSingular: () => i18n.t('organisation unit'), + }, + followUp: { + apiFieldSingular: 'displayFollowUpLabel', + defaultSingular: () => i18n.t('follow-up'), + }, +} satisfies Record; + +export const LabelKeys = { + enrollmentSingular: 'enrollment', + enrollmentPlural: { key: 'enrollment', plural: true }, + eventSingular: 'event', + eventPlural: { key: 'event', plural: true }, + programStageSingular: 'programStage', + programStagePlural: { key: 'programStage', plural: true }, + noteSingular: 'note', + relationshipSingular: 'relationship', + attributeSingular: 'attribute', + orgUnitSingular: 'orgUnit', + followUpSingular: 'followUp', +} as const; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels.ts new file mode 100644 index 0000000000..3266cc270e --- /dev/null +++ b/src/core_modules/capture-core/metaData/helpers/customLabels.ts @@ -0,0 +1,98 @@ +import { useSelector } from 'react-redux'; +import { programCollection } from '../../metaDataMemoryStores'; +import { LABELS, type LabelConfig } from './constants/customLabels.const'; + +export type CustomLabelKey = keyof typeof LABELS; +export type CustomLabels = Record; +export type TermRequest = CustomLabelKey | { key: CustomLabelKey; plural?: boolean }; + +type LabelSource = Record | undefined | null; + +type ProgramScope = { programId: string | null | undefined; stageId?: string | null }; +type OptionalProgramScope = { programId?: string | null; stageId?: string | null }; +type ProgramContainer = { program: LabelSource }; + +const getLabel = (key: CustomLabelKey): LabelConfig => LABELS[key]; + +const ALL_FIELD_NAMES = Object.values(LABELS as Record).flatMap( + ({ apiFieldSingular, apiFieldPlural }) => (apiFieldPlural ? [apiFieldSingular, apiFieldPlural] : [apiFieldSingular]), +); + +const resolveDefault = (key: CustomLabelKey, plural: boolean): string => { + const label = getLabel(key); + return plural ? label.defaultPlural?.() ?? label.defaultSingular() : label.defaultSingular(); +}; + +const resolveLabel = ( + sources: ReadonlyArray, + key: CustomLabelKey, + plural: boolean, +): string => { + const { apiFieldSingular, apiFieldPlural } = getLabel(key); + const target = plural ? apiFieldPlural : apiFieldSingular; + const found = target + ? sources + .map(source => source?.[target]) + .find((value): value is string => typeof value === 'string') + : undefined; + return found ?? resolveDefault(key, plural); +}; + +const resolveFromCollection = ( + programId: string | null | undefined, + stageId: string | null | undefined, + key: CustomLabelKey, + plural: boolean, +): string => { + const program = programId ? programCollection.get(programId) : undefined; + const stage = program && stageId ? program.getStage(stageId) : undefined; + return resolveLabel([stage?.customLabels, program?.customLabels], key, plural); +}; + +const buildLabels = ( + requests: ReadonlyArray, + resolve: (key: CustomLabelKey, plural: boolean) => string, +): CustomLabels => { + const entries = requests.map((req) => { + const isString = typeof req === 'string'; + const key = isString ? req : req.key; + const plural = !isString && (req.plural ?? false); + const outputKey = plural ? `${key}sLabel` : `${key}Label`; + return [outputKey, resolve(key, plural)]; + }); + return Object.fromEntries(entries); +}; + +/** Use in metadata-load code (factories) to pluck label fields from a raw API object. */ +export const extractCustomLabels = (cached: Record): CustomLabels => + Object.fromEntries( + ALL_FIELD_NAMES.flatMap((field) => { + const value = cached[field]; + return typeof value === 'string' ? [[field, value]] : []; + }), + ); + +/** Use outside React (selectors, thunks); reads from `programCollection`. */ +export const getTermLabel = ( + requests: ReadonlyArray, + { programId, stageId }: ProgramScope, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveFromCollection(programId, stageId, key, plural)); + +/** Use in self-contained widgets that already own the program object (no Redux dep). */ +export const getTermLabelFromProgram = ( + requests: ReadonlyArray, + { program }: ProgramContainer, +): CustomLabels => + buildLabels(requests, (key, plural) => resolveLabel([program], key, plural)); + +/** Use inside React components; `programId` falls back to `currentSelections.programId`. */ +export const useTermLabel = ( + requests: ReadonlyArray, + { programId, stageId }: OptionalProgramScope = {}, +): CustomLabels => { + const activeProgramId = useSelector(({ currentSelections }: any) => + programId ?? currentSelections.programId); + return buildLabels(requests, (key, plural) => + resolveFromCollection(activeProgramId, stageId, key, plural)); +}; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts deleted file mode 100644 index 18938840cd..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/customLabels.ts +++ /dev/null @@ -1,73 +0,0 @@ -type CustomLabelField = { - field?: string, - pluralField?: string, -}; - -export const CUSTOM_LABEL_FIELDS = { - enrollment: { field: 'displayEnrollmentLabel', pluralField: 'displayEnrollmentsLabel' }, - followUp: { field: 'displayFollowUpLabel' }, - orgUnit: { field: 'displayOrgUnitLabel' }, - relationship: { field: 'displayRelationshipLabel' }, - note: { field: 'displayNoteLabel' }, - attribute: { field: 'displayTrackedEntityAttributeLabel' }, - programStage: { field: 'displayProgramStageLabel', pluralField: 'displayProgramStagesLabel' }, - event: { field: 'displayEventLabel', pluralField: 'displayEventsLabel' }, - trackedEntityType: { pluralField: 'displayTrackedEntityTypesLabel' }, -} as const satisfies { [key: string]: CustomLabelField }; - -export type CustomLabelKey = keyof typeof CUSTOM_LABEL_FIELDS; -export type CustomLabels = Record; -export type LabelOptions = { plural?: boolean }; - -const allFields: Array = Array.from( - new Set( - Object.values(CUSTOM_LABEL_FIELDS) - .flatMap((term: CustomLabelField) => [term.field, term.pluralField]) - .filter((field): field is string => Boolean(field)), - ), -); - -export const extractCustomLabels = (cached: Record): CustomLabels => { - const labels: CustomLabels = {}; - allFields.forEach((field) => { - if (cached[field]) { - labels[field] = cached[field]; - } - }); - return labels; -}; - -type LabelSource = CustomLabels | undefined | null; - -export const resolveLabel = ( - sources: LabelSource | Array, - key: CustomLabelKey, - { plural = false }: LabelOptions = {}, -): string | undefined => { - const term: CustomLabelField = CUSTOM_LABEL_FIELDS[key]; - const list = Array.isArray(sources) ? sources : [sources]; - const pick = (field?: string) => (field ? list.find(source => source?.[field])?.[field] : undefined); - - if (plural) { - return term.pluralField ? pick(term.pluralField) : pick(term.field); - } - return pick(term.field); -}; - -type WithLabels = { customLabels?: CustomLabels } | undefined | null; - -export const getProgramLabel = (program: WithLabels, key: CustomLabelKey, options?: LabelOptions): string | undefined => - resolveLabel(program?.customLabels, key, options); - -export const getStageLabel = ( - stage: WithLabels, - program: WithLabels, - key: CustomLabelKey, - options?: LabelOptions, -): string | undefined => resolveLabel([stage?.customLabels, program?.customLabels], key, options); - -export const getTrackedEntityTypeLabel = ( - trackedEntityType: WithLabels, - key: CustomLabelKey, - options?: LabelOptions, -): string | undefined => resolveLabel(trackedEntityType?.customLabels, key, options); diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts deleted file mode 100644 index 49b34132fe..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - CUSTOM_LABEL_FIELDS, - resolveLabel, - extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, -} from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; -export { useProgramLabel, useStageLabel, useTrackedEntityTypeLabel } from './useLabel'; diff --git a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts b/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts deleted file mode 100644 index c733c2e662..0000000000 --- a/src/core_modules/capture-core/metaData/helpers/customLabels/useLabel.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { programCollection, trackedEntityTypesCollection } from '../../../metaDataMemoryStores'; -import { resolveLabel } from './customLabels'; -import type { CustomLabelKey, LabelOptions } from './customLabels'; - -type ProgramOptions = LabelOptions & { programId?: string }; -type StageOptions = LabelOptions & { programId?: string, stageId?: string }; -type TrackedEntityTypeOptions = LabelOptions & { tetId?: string }; - -export const useProgramLabel = (key: CustomLabelKey, { programId, plural }: ProgramOptions = {}): string | undefined => { - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const id = programId ?? currentProgramId; - return useMemo( - () => resolveLabel(id ? programCollection.get(id)?.customLabels : undefined, key, { plural }), - [id, key, plural], - ); -}; - -export const useStageLabel = ( - key: CustomLabelKey, - { programId, stageId, plural }: StageOptions = {}, -): string | undefined => { - const currentProgramId = useSelector(({ currentSelections }: any) => currentSelections.programId); - const currentStageId = useSelector(({ currentSelections }: any) => currentSelections.stageId); - const pId = programId ?? currentProgramId; - const sId = stageId ?? currentStageId; - return useMemo(() => { - const program = pId ? programCollection.get(pId) : undefined; - const stage = program && sId ? program.getStage(sId) : undefined; - return resolveLabel([stage?.customLabels, program?.customLabels], key, { plural }); - }, [pId, sId, key, plural]); -}; - -export const useTrackedEntityTypeLabel = ( - key: CustomLabelKey, - { tetId, plural }: TrackedEntityTypeOptions = {}, -): string | undefined => { - const currentTetId = useSelector(({ currentSelections }: any) => currentSelections.trackedEntityTypeId); - const id = tetId ?? currentTetId; - return useMemo( - () => resolveLabel(id ? trackedEntityTypesCollection.get(id)?.customLabels : undefined, key, { plural }), - [id, key, plural], - ); -}; diff --git a/src/core_modules/capture-core/metaData/helpers/index.ts b/src/core_modules/capture-core/metaData/helpers/index.ts index 627adbd3b3..51ded21356 100644 --- a/src/core_modules/capture-core/metaData/helpers/index.ts +++ b/src/core_modules/capture-core/metaData/helpers/index.ts @@ -18,14 +18,13 @@ export { getProgramEventAccess } from './getProgramEventAccess'; export { getProgramAndStageForProgram } from './getProgramAndStageForProgram'; export { getProgramThrowIfNotFound } from './getProgramThrowIfNotFound'; export { - CUSTOM_LABEL_FIELDS, - resolveLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + getTermLabel, + getTermLabelFromProgram, + useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './customLabels'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './customLabels'; +export { LabelKeys } from './constants/customLabels.const'; + diff --git a/src/core_modules/capture-core/metaData/index.ts b/src/core_modules/capture-core/metaData/index.ts index 00e7aca7aa..917eca2738 100644 --- a/src/core_modules/capture-core/metaData/index.ts +++ b/src/core_modules/capture-core/metaData/index.ts @@ -40,14 +40,12 @@ export { getProgramThrowIfNotFound, getProgramAndStageForEventProgram, getEventProgramEventAccess, - CUSTOM_LABEL_FIELDS, - resolveLabel, extractCustomLabels, - getProgramLabel, - getStageLabel, - getTrackedEntityTypeLabel, - useProgramLabel, - useStageLabel, - useTrackedEntityTypeLabel, + getTermLabel, + getTermLabelFromProgram, + LabelKeys, + useTermLabel, + type CustomLabelKey, + type CustomLabels, + type TermRequest, } from './helpers'; -export type { CustomLabelKey, CustomLabels, LabelOptions } from './helpers'; diff --git a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts index 5513dce0ee..fb8c442ede 100644 --- a/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts +++ b/src/core_modules/capture-core/metaDataMemoryStoreBuilders/trackedEntityTypes/factory/TrackedEntityType/TrackedEntityTypeFactory.ts @@ -1,8 +1,5 @@ /* eslint-disable no-underscore-dangle */ -import { - TrackedEntityType, - extractCustomLabels, -} from '../../../../metaData'; +import { TrackedEntityType } from '../../../../metaData'; import { DataElementFactory } from './DataElementFactory'; import { TeiRegistrationFactory } from './TeiRegistrationFactory'; import { SearchGroupFactory } from '../../../common/factory'; @@ -84,7 +81,6 @@ export class TrackedEntityTypeFactory { o.name = this._getTranslation( cachedType.translations, TrackedEntityTypeFactory.translationPropertyNames.NAME) || cachedType.displayName; - o.customLabels = extractCustomLabels(cachedType); }); if (cachedType.trackedEntityTypeAttributes) { diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts index 929645433c..8e226e4880 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/programs/quickStoreOperations/storePrograms.ts @@ -1,3 +1,4 @@ +import { FEATURES, featureAvailable } from 'capture-core-utils/featuresSupport'; import { quickStore } from '../../IOUtils'; import { getContext } from '../../context'; import type { CachedProgramStageDataElement } from '../../../storageControllers'; @@ -97,7 +98,7 @@ const programTrackedEntityAttributeFields = [ 'allowFutureDate', ].join(','); -const programStageFields = [ +const baseProgramStageFields = [ 'id', 'access', 'autoGenerateEvent', @@ -117,7 +118,6 @@ const programStageFields = [ 'displayDueDateLabel', 'displayProgramStageLabel', 'displayEventLabel', - 'displayEventsLabel', 'formType', 'featureType', 'validationStrategy', @@ -126,9 +126,13 @@ const programStageFields = [ 'dataEntryForm[id,htmlCode]', 'programStageSections[id,displayName,displayDescription,sortOrder,dataElements[id]]', `programStageDataElements[${programStageDataElementFields}]`, -].join(','); +]; + +const pluralProgramStageFields = [ + 'displayEventsLabel', +]; -const fieldsParam = [ +const baseProgramFields = [ 'id', 'displayName', 'displayShortName', @@ -138,16 +142,13 @@ const fieldsParam = [ 'displayIncidentDateLabel', 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', - 'displayEnrollmentsLabel', 'displayFollowUpLabel', 'displayOrgUnitLabel', 'displayRelationshipLabel', 'displayNoteLabel', 'displayTrackedEntityAttributeLabel', 'displayProgramStageLabel', - 'displayProgramStagesLabel', 'displayEventLabel', - 'displayEventsLabel', 'minAttributesRequiredToSearch', 'useFirstStageDuringRegistration', 'onlyEnrollOnce', @@ -163,16 +164,38 @@ const fieldsParam = [ 'access[data[read,write]]', 'trackedEntityType[id]', 'categoryCombo[id,displayName,isDefault,categories[id,displayName]]', - `programStages[${programStageFields}]`, 'programSections[id, displayDescription, displayFormName, sortOrder, trackedEntityAttributes]', `programTrackedEntityAttributes[${programTrackedEntityAttributeFields}]`, -].join(','); +]; + +const pluralProgramFields = [ + 'displayEnrollmentsLabel', + 'displayProgramStagesLabel', + 'displayEventsLabel', + 'displayNotesLabel', + 'displayRelationshipsLabel', + 'displayTrackedEntityAttributesLabel', +]; + +const buildFieldsParam = (includePluralLabels: boolean): string => { + const stageFields = includePluralLabels + ? [...baseProgramStageFields, ...pluralProgramStageFields] + : baseProgramStageFields; + const programFields = includePluralLabels + ? [...baseProgramFields, ...pluralProgramFields] + : baseProgramFields; + return [ + ...programFields, + `programStages[${stageFields.join(',')}]`, + ].join(','); +}; export const storePrograms = (programIds: Array) => { + const includePluralLabels = featureAvailable(FEATURES.customTerminologyPlurals); const query = { resource: 'programs', params: { - fields: fieldsParam, + fields: buildFieldsParam(includePluralLabels), filter: `id:in:[${programIds.join(',')}]`, pageSize: programIds.length, }, diff --git a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts index 1e6f04c161..4271797227 100644 --- a/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts +++ b/src/core_modules/capture-core/metaDataStoreLoaders/trackedEntityTypes/quickStoreOperations/storeTrackedEntityTypes.ts @@ -26,7 +26,8 @@ const convert = (() => { })); })(); -const fieldsParam = 'id,access,displayName,displayTrackedEntityTypesLabel,minAttributesRequiredToSearch,featureType,' + +const FIELDS = + 'id,access,displayName,minAttributesRequiredToSearch,featureType,' + 'trackedEntityTypeAttributes[trackedEntityAttribute[id],displayInList,mandatory,searchable],' + 'translations[property,locale,value]'; @@ -34,7 +35,7 @@ export const storeTrackedEntityTypes = (ids: Array) => { const query = { resource: 'trackedEntityTypes', params: { - fields: fieldsParam, + fields: FIELDS, filter: `id:in:[${ids.join(',')}]`, pageSize: ids.length, }, diff --git a/src/i18n/setupFormatters.ts b/src/i18n/setupFormatters.ts new file mode 100644 index 0000000000..25e66657b8 --- /dev/null +++ b/src/i18n/setupFormatters.ts @@ -0,0 +1,48 @@ +/** + * Patches d2-i18n's interpolator for custom terminology. + * + * 1. If the template interpolates any variable listed in CUSTOM_TERM_VARS, + * HTML escaping is disabled for that call. + * All other translations keep default HTML escaping (XSS safety). + * 2. If a custom-term variable is the leading token in a template, its value + * is capitalized (locale-aware). + */ + +import i18n from '@dhis2/d2-i18n'; +import { capitalizeFirstLetter } from 'capture-core-utils/string/capitalizeFirstLetter'; + +const CUSTOM_TERM_VARS = new Set([ + 'enrollmentLabel', 'enrollmentsLabel', + 'eventLabel', 'eventsLabel', + 'programStageLabel', 'programStagesLabel', + 'linkableStageLabel', + 'followUpLabel', 'orgUnitLabel', + 'relationshipLabel', + 'noteLabel', + 'attributeLabel', +]); + +const interpolator = (i18n as any).services?.interpolator; +if (interpolator) { + const original = interpolator.interpolate.bind(interpolator); + + interpolator.interpolate = (template: string, data: Record, lng: string, opts: any) => { + const usedVars = [...template.matchAll(/\{\{\s*(\w+)/g)].map(m => m[1]); + if (!usedVars.some(name => CUSTOM_TERM_VARS.has(name))) { + return original(template, data, lng, opts); + } + + const leading = /^\{\{\s*(\w+)/.exec(template.trimStart())?.[1]; + const preparedData = leading && CUSTOM_TERM_VARS.has(leading) && typeof data?.[leading] === 'string' + ? { ...data, [leading]: capitalizeFirstLetter(data[leading] as string) } + : (data ?? {}); + + const previousEscape = interpolator.escapeValue; + interpolator.escapeValue = false; + try { + return original(template, preparedData, lng, opts); + } finally { + interpolator.escapeValue = previousEscape; + } + }; +} diff --git a/src/index.tsx b/src/index.tsx index c25ff0c33c..d0f190a285 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; import 'regenerator-runtime'; // To fix the 'regeneratorRuntime is not defined' error comming from react-leaflet-search-unpolyfilled import 'capture-core-utils/extensions/asyncForEachArray'; import 'capture-core-utils/extensions/arrayToHashMap'; +import './i18n/setupFormatters'; import './locales'; //eslint-disable-line import { AppStart } from './components/AppStart';