From 36d11935bd7ec5bfc95c688979a1b728d3b09a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Fri, 9 Oct 2026 12:02:23 +0200 Subject: [PATCH 01/11] feat: first pass WIP --- .../PeriodDimension/PeriodDimension.js | 4 +- .../PeriodDimension/PeriodTransfer.js | 91 +----- .../PeriodDimension/__tests__/v44.spec.js | 250 +++++++++++++++ .../useDataOutputPeriodTypes.js | 198 +++--------- .../utils/enabledPeriodTypes.js | 106 ------- .../PeriodDimension/versions/v42.js | 33 ++ .../PeriodDimension/versions/v43.js | 296 ++++++++++++++++++ .../PeriodDimension/versions/v44.js | 191 +++++++++++ 8 files changed, 832 insertions(+), 337 deletions(-) create mode 100644 src/components/PeriodDimension/__tests__/v44.spec.js create mode 100644 src/components/PeriodDimension/versions/v42.js create mode 100644 src/components/PeriodDimension/versions/v43.js create mode 100644 src/components/PeriodDimension/versions/v44.js diff --git a/src/components/PeriodDimension/PeriodDimension.js b/src/components/PeriodDimension/PeriodDimension.js index 9e7b47607..e149030bd 100644 --- a/src/components/PeriodDimension/PeriodDimension.js +++ b/src/components/PeriodDimension/PeriodDimension.js @@ -29,8 +29,7 @@ const PeriodDimension = ({ const { systemInfo } = config const userSettingsResult = useDataQuery(userSettingsQuery) - const { supportsEnabledPeriodTypes, enabledPeriodTypesData } = - useDataOutputPeriodTypes() + const { enabledPeriodTypesData } = useDataOutputPeriodTypes() const { calendar = 'gregory' } = systemInfo const { data: { userSettings: { keyUiLocale: locale } = {} } = {} } = @@ -61,7 +60,6 @@ const PeriodDimension = ({ periodsSettings={periodsSettings} height={height} enabledPeriodTypesData={enabledPeriodTypesData} - supportsEnabledPeriodTypes={supportsEnabledPeriodTypes} /> ) } diff --git a/src/components/PeriodDimension/PeriodTransfer.js b/src/components/PeriodDimension/PeriodTransfer.js index 7d776d982..d89df976d 100644 --- a/src/components/PeriodDimension/PeriodTransfer.js +++ b/src/components/PeriodDimension/PeriodTransfer.js @@ -13,23 +13,8 @@ import styles from '../styles/DimensionSelector.style.js' import { TransferOption } from '../TransferOption.js' import FixedPeriodFilter from './FixedPeriodFilter.js' import RelativePeriodFilter from './RelativePeriodFilter.js' -import { - applyDisplayLabelOverrides, - applyFixedPeriodTypeDisplayLabels, - filterEnabledFixedPeriodTypes, - filterEnabledRelativePeriodTypes, -} from './utils/enabledPeriodTypes.js' -import { getFixedPeriodsOptions } from './utils/fixedPeriods.js' -import { - FYFEB, - FYAUG, - FYSEP, - MONTHLY, - QUARTERLY, - WEEKLYFRI, - filterPeriodTypesById, -} from './utils/index.js' -import { getRelativePeriodsOptions } from './utils/relativePeriods.js' +import { getPeriodTypeOptions } from './useDataOutputPeriodTypes.js' +import { MONTHLY, QUARTERLY } from './utils/index.js' const RightHeader = ({ infoBoxMessage }) => ( <> @@ -63,65 +48,19 @@ const PeriodTransfer = ({ infoBoxMessage, height = TRANSFER_HEIGHT, enabledPeriodTypesData = null, - supportsEnabledPeriodTypes = false, }) => { - const { filteredFixedOptions, filteredRelativeOptions } = useMemo(() => { - if (supportsEnabledPeriodTypes && enabledPeriodTypesData) { - const { - enabledTypes, - financialYearStart, - financialYearDisplayLabel, - weeklyDisplayLabel, - metaData, - } = enabledPeriodTypesData - - const filteredFixed = applyFixedPeriodTypeDisplayLabels( - filterEnabledFixedPeriodTypes( - getFixedPeriodsOptions(periodsSettings), - enabledTypes - ), - enabledTypes - ) - - const filteredRelative = applyDisplayLabelOverrides( - filterEnabledRelativePeriodTypes( - getRelativePeriodsOptions(), - enabledTypes, - financialYearStart - ), - { financialYearDisplayLabel, weeklyDisplayLabel, metaData } - ) - - return { - filteredFixedOptions: filteredFixed, - filteredRelativeOptions: filteredRelative, - } - } else { - const allFixed = getFixedPeriodsOptions(periodsSettings) - const allRelative = getRelativePeriodsOptions() - const v43PeriodTypes = [WEEKLYFRI, FYFEB, FYAUG, FYSEP] - const allExcludedPeriodTypes = [ - ...excludedPeriodTypes, - ...v43PeriodTypes, - ] - - return { - filteredFixedOptions: filterPeriodTypesById( - allFixed, - allExcludedPeriodTypes - ), - filteredRelativeOptions: filterPeriodTypesById( - allRelative, - allExcludedPeriodTypes - ), - } - } - }, [ - supportsEnabledPeriodTypes, - enabledPeriodTypesData, - excludedPeriodTypes, - periodsSettings, - ]) + const { + fixedOptions: filteredFixedOptions, + relativeOptions: filteredRelativeOptions, + } = useMemo( + () => + getPeriodTypeOptions({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, + }), + [enabledPeriodTypesData, excludedPeriodTypes, periodsSettings] + ) const analysisRelativePeriod = enabledPeriodTypesData?.analysisRelativePeriod @@ -392,6 +331,7 @@ PeriodTransfer.propTypes = { financialYearDisplayLabel: PropTypes.string, financialYearStart: PropTypes.string, noEnabledTypes: PropTypes.bool, + version: PropTypes.number, }), excludedPeriodTypes: PropTypes.arrayOf(PropTypes.string), height: PropTypes.string, @@ -408,7 +348,6 @@ PeriodTransfer.propTypes = { name: PropTypes.string, }) ), - supportsEnabledPeriodTypes: PropTypes.bool, } export default PeriodTransfer diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js new file mode 100644 index 000000000..f15e525a7 --- /dev/null +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -0,0 +1,250 @@ +import { renderHook } from '@testing-library/react' +import { + getV44PeriodTypeOptions, + useV44DataOutputPeriodTypes, +} from '../versions/v44.js' + +jest.mock('@dhis2/app-runtime', () => ({ + useDataQuery: jest.fn(), +})) + +// Shape of /api/configuration/dataOutputPeriodTypes?fields=name,displayName,relativePeriods (v44) +const daily = { + name: 'Daily', + displayName: 'Quotidien', + relativePeriods: { + TODAY: { displayName: "Aujourd'hui" }, + YESTERDAY: { displayName: 'Hier' }, + }, +} +const weekly = { + name: 'Weekly', + displayName: 'Hebdomadaire', + relativePeriods: { + THIS_WEEK: { displayName: 'Cette semaine' }, + LAST_WEEK: { displayName: 'Semaine dernière' }, + }, +} +const weeklySunday = { name: 'WeeklySunday', displayName: 'EPI Week' } +const monthly = { + name: 'Monthly', + displayName: 'Mois', + relativePeriods: { + THIS_MONTH: { displayName: 'Ce mois-ci' }, + LAST_MONTH: { displayName: 'x-Last_Month' }, + MONTHS_LAST_YEAR: { displayName: "Mois de l'an dernier" }, + }, +} +const quarterlyNov = { name: 'QuarterlyNov', displayName: 'Quarterly Nov' } +const financialApril = { name: 'FinancialApril', displayName: 'FY April' } +const financialOct = { name: 'FinancialOct', displayName: 'FY October' } + +const getOptions = (enabledTypes, excludedPeriodTypes = []) => + getV44PeriodTypeOptions({ + enabledPeriodTypesData: { version: 44, enabledTypes }, + periodsSettings: { calendar: 'gregory', locale: 'en' }, + excludedPeriodTypes, + }) + +const summarize = (options) => + options.map(({ id, name, getPeriods }) => ({ + id, + name, + periods: getPeriods().map((p) => `${p.id}:${p.name}`), + })) + +describe('v44 period type options', () => { + it('uses server displayName for fixed period types, in server order', () => { + const { fixedOptions } = getOptions([ + daily, + weekly, + weeklySunday, + monthly, + financialApril, + ]) + + expect(fixedOptions.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 'DAILY', name: 'Quotidien' }, + { id: 'WEEKLY', name: 'Hebdomadaire' }, + { id: 'WEEKLYSUN', name: 'EPI Week' }, + { id: 'MONTHLY', name: 'Mois' }, + { id: 'FYAPR', name: 'FY April' }, + ]) + expect(typeof fixedOptions[0].getPeriods).toBe('function') + }) + + it('skips server period types the client cannot generate periods for', () => { + const { fixedOptions } = getOptions([monthly, quarterlyNov]) + + expect(fixedOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + }) + + it('uses the nested relative periods with server displayName', () => { + const { relativeOptions } = getOptions([daily, weekly, monthly]) + + expect(summarize(relativeOptions)).toEqual([ + { + id: 'DAILY', + name: 'Quotidien', + periods: ["TODAY:Aujourd'hui", 'YESTERDAY:Hier'], + }, + { + id: 'WEEKLY', + name: 'Hebdomadaire', + periods: [ + 'THIS_WEEK:Cette semaine', + 'LAST_WEEK:Semaine dernière', + ], + }, + { + id: 'MONTHLY', + name: 'Mois', + periods: [ + 'THIS_MONTH:Ce mois-ci', + 'LAST_MONTH:x-Last_Month', + "MONTHS_LAST_YEAR:Mois de l'an dernier", + ], + }, + ]) + }) + + it('shows one weekly category when several weekly types are enabled', () => { + const { relativeOptions } = getOptions([weekly, weeklySunday]) + + expect(relativeOptions.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 'WEEKLY', name: 'Hebdomadaire' }, + ]) + }) + + it('falls back to client relative periods when the server does not nest them', () => { + // Weekly disabled: server nests weekly relative periods under Weekly only + const { relativeOptions } = getOptions([weeklySunday]) + + expect(relativeOptions).toHaveLength(1) + expect(relativeOptions[0].id).toBe('WEEKLY') + expect(relativeOptions[0].name).toBe('EPI Week') + expect(relativeOptions[0].getPeriods().map(({ id }) => id)).toContain( + 'THIS_WEEK' + ) + }) + + it('shows financial year relative periods when any financial type is enabled', () => { + const single = getOptions([financialOct]).relativeOptions + expect(single.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 'FINANCIAL', name: 'FY October' }, + ]) + expect(single[0].getPeriods().map(({ id }) => id)).toContain( + 'THIS_FINANCIAL_YEAR' + ) + + // ambiguous family label, use the client category name + const multiple = getOptions([ + financialApril, + financialOct, + ]).relativeOptions + expect(multiple.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 'FINANCIAL', name: 'Financial Years' }, + ]) + }) + + it('applies excludedPeriodTypes to both fixed and relative options', () => { + const { fixedOptions, relativeOptions } = getOptions( + [daily, weekly, monthly], + ['DAILY', 'WEEKLY'] + ) + + expect(fixedOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + expect(relativeOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + }) + + it('returns no options when no period types are enabled', () => { + expect(getOptions([])).toEqual({ + fixedOptions: [], + relativeOptions: [], + }) + }) +}) + +describe('useV44DataOutputPeriodTypes', () => { + const mockQueryState = {} + + beforeEach(() => { + const { useDataQuery } = require('@dhis2/app-runtime') + mockQueryState.v44Refetch = jest.fn() + mockQueryState.localeRefetch = jest.fn() + mockQueryState.v44Data = undefined + + useDataQuery.mockImplementation((query, options) => { + if (query.userSettings) { + mockQueryState.localeOptions = options + return { refetch: mockQueryState.localeRefetch } + } + return { + data: mockQueryState.v44Data, + error: undefined, + refetch: mockQueryState.v44Refetch, + } + }) + }) + + it('does not fetch when disabled', () => { + renderHook(() => useV44DataOutputPeriodTypes({ enabled: false })) + + expect(mockQueryState.localeRefetch).not.toHaveBeenCalled() + expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() + }) + + it('fetches period types in the user ui locale', () => { + renderHook(() => useV44DataOutputPeriodTypes({ enabled: true })) + + expect(mockQueryState.localeRefetch).toHaveBeenCalledTimes(1) + + mockQueryState.localeOptions.onComplete({ + userSettings: { keyUiLocale: 'fr' }, + }) + expect(mockQueryState.v44Refetch).toHaveBeenCalledWith({ + locale: 'fr', + }) + + // still fetches period types if the user locale request fails + mockQueryState.localeOptions.onError() + expect(mockQueryState.v44Refetch).toHaveBeenLastCalledWith({}) + }) + + it('returns enabled types and relative period name overrides', () => { + mockQueryState.v44Data = { + periodTypes: [daily, monthly], + analysisRelativePeriod: { + keyAnalysisRelativePeriod: 'LAST_12_MONTHS', + }, + } + + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current).toEqual({ + version: 44, + enabledTypes: [daily, monthly], + analysisRelativePeriod: 'LAST_12_MONTHS', + noEnabledTypes: false, + metaData: { + TODAY: { name: "Aujourd'hui" }, + YESTERDAY: { name: 'Hier' }, + THIS_MONTH: { name: 'Ce mois-ci' }, + LAST_MONTH: { name: 'x-Last_Month' }, + MONTHS_LAST_YEAR: { name: "Mois de l'an dernier" }, + }, + }) + }) + + it('flags when no period types are enabled', () => { + mockQueryState.v44Data = { periodTypes: [] } + + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current.noEnabledTypes).toBe(true) + }) +}) diff --git a/src/components/PeriodDimension/useDataOutputPeriodTypes.js b/src/components/PeriodDimension/useDataOutputPeriodTypes.js index f76031fdb..d76d05b93 100644 --- a/src/components/PeriodDimension/useDataOutputPeriodTypes.js +++ b/src/components/PeriodDimension/useDataOutputPeriodTypes.js @@ -1,163 +1,57 @@ -import { useConfig, useDataQuery } from '@dhis2/app-runtime' -import { useEffect, useMemo } from 'react' - -const v43Query = { - enabledPeriodTypes: { - resource: 'configuration/dataOutputPeriodTypes', - }, - // v43-only: analyticsFinancialYearStart is removed in v44 - financialYearStart: { - resource: 'systemSettings/analyticsFinancialYearStart', - }, - analysisRelativePeriod: { - resource: 'systemSettings/keyAnalysisRelativePeriod', - }, - // v43-only: analyticsWeeklyStart is removed in v44 - weeklyStart: { - resource: 'systemSettings/analyticsWeeklyStart', - }, -} - -// v43-only: analyticsFinancialYearStart is removed in v44 -const FY_SETTING_TO_SERVER_PT = { - FINANCIAL_YEAR_FEBRUARY: 'FinancialFeb', - FINANCIAL_YEAR_APRIL: 'FinancialApril', - FINANCIAL_YEAR_JULY: 'FinancialJuly', - FINANCIAL_YEAR_AUGUST: 'FinancialAug', - FINANCIAL_YEAR_SEPTEMBER: 'FinancialSep', - FINANCIAL_YEAR_OCTOBER: 'FinancialOct', - FINANCIAL_YEAR_NOVEMBER: 'FinancialNov', -} - -// v43-only: analyticsWeeklyStart is removed in v44 -const WEEKLY_START_TO_SERVER_PT = { - WEEKLY: 'Weekly', - WEEKLY_WEDNESDAY: 'WeeklyWednesday', - WEEKLY_THURSDAY: 'WeeklyThursday', - WEEKLY_FRIDAY: 'WeeklyFriday', - WEEKLY_SATURDAY: 'WeeklySaturday', - WEEKLY_SUNDAY: 'WeeklySunday', -} +import { useConfig } from '@dhis2/app-runtime' +import { getV42PeriodTypeOptions } from './versions/v42.js' +import { + getV43PeriodTypeOptions, + useV43DataOutputPeriodTypes, +} from './versions/v43.js' +import { + getV44PeriodTypeOptions, + useV44DataOutputPeriodTypes, +} from './versions/v44.js' const useDataOutputPeriodTypes = () => { const { serverVersion } = useConfig() const supportsEnabledPeriodTypes = serverVersion.minor >= 43 + const supportsPeriodLabelTranslations = serverVersion.minor >= 44 - const { - data: v43Data, - error: v43Error, - refetch: v43Refetch, - } = useDataQuery(v43Query, { lazy: true }) - - useEffect(() => { - if (supportsEnabledPeriodTypes) { - v43Refetch() - } - }, [supportsEnabledPeriodTypes, v43Refetch]) - - const enabledPeriodTypesData = useMemo(() => { - if (!supportsEnabledPeriodTypes) { - return null - } - - if (v43Error || !v43Data?.enabledPeriodTypes) { - return null - } - - const enabledTypes = v43Data.enabledPeriodTypes - - if (!enabledTypes || enabledTypes.length === 0) { - return { - enabledTypes: [], - financialYearStart: null, - analysisRelativePeriod: null, - noEnabledTypes: true, - } - } - - // v43-only: financial year logic goes away in v44 - let financialYearStart = null - let financialYearDisplayLabel = null - if (v43Data.financialYearStart?.analyticsFinancialYearStart) { - const fyStartValue = - v43Data.financialYearStart.analyticsFinancialYearStart - - const mappedFyPt = FY_SETTING_TO_SERVER_PT[fyStartValue] - const matchingPt = enabledTypes.find((pt) => pt.name === mappedFyPt) - if (matchingPt) { - financialYearStart = fyStartValue - if (matchingPt.displayLabel) { - financialYearDisplayLabel = matchingPt.displayLabel - } - } - } - - // v43-only: weekly start logic goes away in v44 - let weeklyDisplayLabel = null - if (v43Data.weeklyStart?.analyticsWeeklyStart) { - const weeklyStartValue = v43Data.weeklyStart.analyticsWeeklyStart - - const mappedWeeklyPt = WEEKLY_START_TO_SERVER_PT[weeklyStartValue] - const matchingWeeklyPt = enabledTypes.find( - (pt) => pt.name === mappedWeeklyPt - ) - if (matchingWeeklyPt?.displayLabel) { - weeklyDisplayLabel = matchingWeeklyPt.displayLabel - } - } - - const analysisRelativePeriod = - v43Data.analysisRelativePeriod?.keyAnalysisRelativePeriod || null - - const metaData = { - ...(financialYearDisplayLabel && { - THIS_FINANCIAL_YEAR: { - name: `This ${financialYearDisplayLabel}`, - }, - LAST_FINANCIAL_YEAR: { - name: `Last ${financialYearDisplayLabel}`, - }, - LAST_5_FINANCIAL_YEARS: { - name: `Last 5 ${financialYearDisplayLabel}`, - }, - }), - ...(weeklyDisplayLabel && { - THIS_WEEK: { - name: `This ${weeklyDisplayLabel}`, - }, - LAST_WEEK: { - name: `Last ${weeklyDisplayLabel}`, - }, - LAST_4_WEEKS: { - name: `Last 4 ${weeklyDisplayLabel}`, - }, - LAST_12_WEEKS: { - name: `Last 12 ${weeklyDisplayLabel}`, - }, - LAST_52_WEEKS: { - name: `Last 52 ${weeklyDisplayLabel}`, - }, - WEEKS_THIS_YEAR: { - name: `${weeklyDisplayLabel} this year`, - }, - }), - } - - return { - enabledTypes, - financialYearStart, - financialYearDisplayLabel, - weeklyDisplayLabel, - analysisRelativePeriod, - metaData, - noEnabledTypes: false, - } - }, [supportsEnabledPeriodTypes, v43Data, v43Error]) + const v43Data = useV43DataOutputPeriodTypes({ + enabled: supportsEnabledPeriodTypes && !supportsPeriodLabelTranslations, + }) + const v44Data = useV44DataOutputPeriodTypes({ + enabled: supportsPeriodLabelTranslations, + }) return { supportsEnabledPeriodTypes, - enabledPeriodTypesData, + enabledPeriodTypesData: supportsPeriodLabelTranslations + ? v44Data + : v43Data, + } +} + +const getPeriodTypeOptions = ({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, +}) => { + switch (enabledPeriodTypesData?.version) { + case 44: + return getV44PeriodTypeOptions({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, + }) + case 43: + return getV43PeriodTypeOptions({ + enabledPeriodTypesData, + periodsSettings, + }) + default: + return getV42PeriodTypeOptions({ + periodsSettings, + excludedPeriodTypes, + }) } } -export { useDataOutputPeriodTypes } +export { useDataOutputPeriodTypes, getPeriodTypeOptions } diff --git a/src/components/PeriodDimension/utils/enabledPeriodTypes.js b/src/components/PeriodDimension/utils/enabledPeriodTypes.js index 7cadc0833..6a104d912 100644 --- a/src/components/PeriodDimension/utils/enabledPeriodTypes.js +++ b/src/components/PeriodDimension/utils/enabledPeriodTypes.js @@ -77,48 +77,8 @@ export const filterEnabledFixedPeriodTypes = ( ) } -/** - * Apply displayLabel overrides to fixed period type names - * v43-only: in v44 the API provides these names directly - */ -export const applyFixedPeriodTypeDisplayLabels = ( - filteredFixedOptions, - enabledServerPeriodTypes -) => { - if (!enabledServerPeriodTypes) { - return filteredFixedOptions - } - - const displayLabelMap = new Map() - enabledServerPeriodTypes.forEach((pt) => { - if (pt.displayLabel) { - const multiCalendarPt = SERVER_PT_TO_MULTI_CALENDAR_PT[pt.name] - if (multiCalendarPt) { - displayLabelMap.set(multiCalendarPt, pt.displayLabel) - } - } - }) - - if (displayLabelMap.size === 0) { - return filteredFixedOptions - } - - return filteredFixedOptions.map((option) => { - const displayLabel = displayLabelMap.get(option.id) - return displayLabel ? { ...option, name: displayLabel } : option - }) -} - -/** - * Filter relative period categories based on enabled server period types - * @param {Array} allRelativePeriodOptions - All available relative period options - * @param {Array} enabledServerPeriodTypes - Enabled period types from server - * @param {string|null} financialYearStart - Financial year start setting (if enabled) - * @returns {Array} Filtered relative period options - */ /** * Apply metaData name overrides to a list of periods - * v43-only: in v44 the API provides these names directly */ export const applyPeriodNameOverrides = (periods, metaData) => { if (!metaData) { @@ -130,69 +90,3 @@ export const applyPeriodNameOverrides = (periods, metaData) => { : period ) } - -/** - * Apply display label overrides to relative period options - * v43-only: in v44 the API provides these names directly - */ -export const applyDisplayLabelOverrides = ( - filteredRelativeOptions, - { financialYearDisplayLabel, weeklyDisplayLabel, metaData } -) => { - const overrides = {} - - if (financialYearDisplayLabel) { - overrides['FINANCIAL'] = { name: financialYearDisplayLabel } - } - if (weeklyDisplayLabel) { - overrides['WEEKLY'] = { name: weeklyDisplayLabel } - } - - if (Object.keys(overrides).length === 0) { - return filteredRelativeOptions - } - - return filteredRelativeOptions.map((option) => { - const override = overrides[option.id] - if (!override) { - return option - } - return { - ...option, - name: override.name, - getPeriods: () => - applyPeriodNameOverrides(option.getPeriods(), metaData), - } - }) -} - -export const filterEnabledRelativePeriodTypes = ( - allRelativePeriodOptions, - enabledServerPeriodTypes, - financialYearStart = null -) => { - if (!enabledServerPeriodTypes || enabledServerPeriodTypes.length === 0) { - return [] - } - - const enabledServerPtNames = new Set( - enabledServerPeriodTypes.map((pt) => pt.name) - ) - - return allRelativePeriodOptions.filter((option) => { - // Special handling for financial years - if (option.id === 'FINANCIAL') { - return financialYearStart !== null - } - - // Check if any of the required FP dependencies are enabled - const requiredFpTypes = RP_CATEGORY_TO_FP_DEPENDENCIES[option.id] - if (!requiredFpTypes) { - return true // Show if no dependency mapping (shouldn't happen) - } - - return requiredFpTypes.some((fpType) => - enabledServerPtNames.has(fpType) - ) - }) -} diff --git a/src/components/PeriodDimension/versions/v42.js b/src/components/PeriodDimension/versions/v42.js new file mode 100644 index 000000000..c62fcbe6a --- /dev/null +++ b/src/components/PeriodDimension/versions/v42.js @@ -0,0 +1,33 @@ +// v42: no enabled period types API, all period types and relative periods +// come from the hardcoded client lists. +// Remove this file (and its branch in getPeriodTypeOptions) when v42 is no longer supported. +import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' +import { + FYAUG, + FYFEB, + FYSEP, + WEEKLYFRI, + filterPeriodTypesById, +} from '../utils/index.js' +import { getRelativePeriodsOptions } from '../utils/relativePeriods.js' + +// period types that were introduced in v43 +const V43_PERIOD_TYPES = [WEEKLYFRI, FYFEB, FYAUG, FYSEP] + +export const getV42PeriodTypeOptions = ({ + periodsSettings, + excludedPeriodTypes, +}) => { + const allExcludedPeriodTypes = [...excludedPeriodTypes, ...V43_PERIOD_TYPES] + + return { + fixedOptions: filterPeriodTypesById( + getFixedPeriodsOptions(periodsSettings), + allExcludedPeriodTypes + ), + relativeOptions: filterPeriodTypesById( + getRelativePeriodsOptions(), + allExcludedPeriodTypes + ), + } +} diff --git a/src/components/PeriodDimension/versions/v43.js b/src/components/PeriodDimension/versions/v43.js new file mode 100644 index 000000000..7f1109379 --- /dev/null +++ b/src/components/PeriodDimension/versions/v43.js @@ -0,0 +1,296 @@ +// v43: enabled period types from /configuration/dataOutputPeriodTypes, with a +// single `label` per period type. Relative period names are derived client side +// from that label ("This {label}", "Last {label}", ...). +// Remove this file (and its branch in useDataOutputPeriodTypes and +// getPeriodTypeOptions) when v43 is no longer supported. +import { useDataQuery } from '@dhis2/app-runtime' +import { useEffect, useMemo } from 'react' +import { + RP_CATEGORY_TO_FP_DEPENDENCIES, + SERVER_PT_TO_MULTI_CALENDAR_PT, + applyPeriodNameOverrides, + filterEnabledFixedPeriodTypes, +} from '../utils/enabledPeriodTypes.js' +import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' +import { getRelativePeriodsOptions } from '../utils/relativePeriods.js' + +const v43Query = { + enabledPeriodTypes: { + resource: 'configuration/dataOutputPeriodTypes', + }, + financialYearStart: { + resource: 'systemSettings/analyticsFinancialYearStart', + }, + analysisRelativePeriod: { + resource: 'systemSettings/keyAnalysisRelativePeriod', + }, + weeklyStart: { + resource: 'systemSettings/analyticsWeeklyStart', + }, +} + +const FY_SETTING_TO_SERVER_PT = { + FINANCIAL_YEAR_FEBRUARY: 'FinancialFeb', + FINANCIAL_YEAR_APRIL: 'FinancialApril', + FINANCIAL_YEAR_JULY: 'FinancialJuly', + FINANCIAL_YEAR_AUGUST: 'FinancialAug', + FINANCIAL_YEAR_SEPTEMBER: 'FinancialSep', + FINANCIAL_YEAR_OCTOBER: 'FinancialOct', + FINANCIAL_YEAR_NOVEMBER: 'FinancialNov', +} + +const WEEKLY_START_TO_SERVER_PT = { + WEEKLY: 'Weekly', + WEEKLY_WEDNESDAY: 'WeeklyWednesday', + WEEKLY_THURSDAY: 'WeeklyThursday', + WEEKLY_FRIDAY: 'WeeklyFriday', + WEEKLY_SATURDAY: 'WeeklySaturday', + WEEKLY_SUNDAY: 'WeeklySunday', +} + +export const useV43DataOutputPeriodTypes = ({ enabled }) => { + const { + data: v43Data, + error: v43Error, + refetch: v43Refetch, + } = useDataQuery(v43Query, { lazy: true }) + + useEffect(() => { + if (enabled) { + v43Refetch() + } + }, [enabled, v43Refetch]) + + return useMemo(() => { + if (!enabled) { + return null + } + + if (v43Error || !v43Data?.enabledPeriodTypes) { + return null + } + + const enabledTypes = v43Data.enabledPeriodTypes + + if (!enabledTypes || enabledTypes.length === 0) { + return { + version: 43, + enabledTypes: [], + financialYearStart: null, + analysisRelativePeriod: null, + noEnabledTypes: true, + } + } + + let financialYearStart = null + let financialYearDisplayLabel = null + if (v43Data.financialYearStart?.analyticsFinancialYearStart) { + const fyStartValue = + v43Data.financialYearStart.analyticsFinancialYearStart + + const mappedFyPt = FY_SETTING_TO_SERVER_PT[fyStartValue] + const matchingPt = enabledTypes.find((pt) => pt.name === mappedFyPt) + if (matchingPt) { + financialYearStart = fyStartValue + if (matchingPt.displayLabel) { + financialYearDisplayLabel = matchingPt.displayLabel + } + } + } + + let weeklyDisplayLabel = null + if (v43Data.weeklyStart?.analyticsWeeklyStart) { + const weeklyStartValue = v43Data.weeklyStart.analyticsWeeklyStart + + const mappedWeeklyPt = WEEKLY_START_TO_SERVER_PT[weeklyStartValue] + const matchingWeeklyPt = enabledTypes.find( + (pt) => pt.name === mappedWeeklyPt + ) + if (matchingWeeklyPt?.displayLabel) { + weeklyDisplayLabel = matchingWeeklyPt.displayLabel + } + } + + const analysisRelativePeriod = + v43Data.analysisRelativePeriod?.keyAnalysisRelativePeriod || null + + const metaData = { + ...(financialYearDisplayLabel && { + THIS_FINANCIAL_YEAR: { + name: `This ${financialYearDisplayLabel}`, + }, + LAST_FINANCIAL_YEAR: { + name: `Last ${financialYearDisplayLabel}`, + }, + LAST_5_FINANCIAL_YEARS: { + name: `Last 5 ${financialYearDisplayLabel}`, + }, + }), + ...(weeklyDisplayLabel && { + THIS_WEEK: { + name: `This ${weeklyDisplayLabel}`, + }, + LAST_WEEK: { + name: `Last ${weeklyDisplayLabel}`, + }, + LAST_4_WEEKS: { + name: `Last 4 ${weeklyDisplayLabel}`, + }, + LAST_12_WEEKS: { + name: `Last 12 ${weeklyDisplayLabel}`, + }, + LAST_52_WEEKS: { + name: `Last 52 ${weeklyDisplayLabel}`, + }, + WEEKS_THIS_YEAR: { + name: `${weeklyDisplayLabel} this year`, + }, + }), + } + + return { + version: 43, + enabledTypes, + financialYearStart, + financialYearDisplayLabel, + weeklyDisplayLabel, + analysisRelativePeriod, + metaData, + noEnabledTypes: false, + } + }, [enabled, v43Data, v43Error]) +} + +/** + * Apply displayLabel overrides to fixed period type names + */ +export const applyFixedPeriodTypeDisplayLabels = ( + filteredFixedOptions, + enabledServerPeriodTypes +) => { + if (!enabledServerPeriodTypes) { + return filteredFixedOptions + } + + const displayLabelMap = new Map() + enabledServerPeriodTypes.forEach((pt) => { + if (pt.displayLabel) { + const multiCalendarPt = SERVER_PT_TO_MULTI_CALENDAR_PT[pt.name] + if (multiCalendarPt) { + displayLabelMap.set(multiCalendarPt, pt.displayLabel) + } + } + }) + + if (displayLabelMap.size === 0) { + return filteredFixedOptions + } + + return filteredFixedOptions.map((option) => { + const displayLabel = displayLabelMap.get(option.id) + return displayLabel ? { ...option, name: displayLabel } : option + }) +} + +/** + * Apply display label overrides to relative period options + */ +export const applyDisplayLabelOverrides = ( + filteredRelativeOptions, + { financialYearDisplayLabel, weeklyDisplayLabel, metaData } +) => { + const overrides = {} + + if (financialYearDisplayLabel) { + overrides['FINANCIAL'] = { name: financialYearDisplayLabel } + } + if (weeklyDisplayLabel) { + overrides['WEEKLY'] = { name: weeklyDisplayLabel } + } + + if (Object.keys(overrides).length === 0) { + return filteredRelativeOptions + } + + return filteredRelativeOptions.map((option) => { + const override = overrides[option.id] + if (!override) { + return option + } + return { + ...option, + name: override.name, + getPeriods: () => + applyPeriodNameOverrides(option.getPeriods(), metaData), + } + }) +} + +/** + * Filter relative period categories based on enabled server period types + * @param {Array} allRelativePeriodOptions - All available relative period options + * @param {Array} enabledServerPeriodTypes - Enabled period types from server + * @param {string|null} financialYearStart - Financial year start setting (if enabled) + * @returns {Array} Filtered relative period options + */ +export const filterEnabledRelativePeriodTypes = ( + allRelativePeriodOptions, + enabledServerPeriodTypes, + financialYearStart = null +) => { + if (!enabledServerPeriodTypes || enabledServerPeriodTypes.length === 0) { + return [] + } + + const enabledServerPtNames = new Set( + enabledServerPeriodTypes.map((pt) => pt.name) + ) + + return allRelativePeriodOptions.filter((option) => { + // Special handling for financial years + if (option.id === 'FINANCIAL') { + return financialYearStart !== null + } + + // Check if any of the required FP dependencies are enabled + const requiredFpTypes = RP_CATEGORY_TO_FP_DEPENDENCIES[option.id] + if (!requiredFpTypes) { + return true // Show if no dependency mapping (shouldn't happen) + } + + return requiredFpTypes.some((fpType) => + enabledServerPtNames.has(fpType) + ) + }) +} + +export const getV43PeriodTypeOptions = ({ + enabledPeriodTypesData, + periodsSettings, +}) => { + const { + enabledTypes, + financialYearStart, + financialYearDisplayLabel, + weeklyDisplayLabel, + metaData, + } = enabledPeriodTypesData + + return { + fixedOptions: applyFixedPeriodTypeDisplayLabels( + filterEnabledFixedPeriodTypes( + getFixedPeriodsOptions(periodsSettings), + enabledTypes + ), + enabledTypes + ), + relativeOptions: applyDisplayLabelOverrides( + filterEnabledRelativePeriodTypes( + getRelativePeriodsOptions(), + enabledTypes, + financialYearStart + ), + { financialYearDisplayLabel, weeklyDisplayLabel, metaData } + ), + } +} diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js new file mode 100644 index 000000000..dff0d4d3c --- /dev/null +++ b/src/components/PeriodDimension/versions/v44.js @@ -0,0 +1,191 @@ +// v44: /configuration/dataOutputPeriodTypes returns the enabled period types +// with their relative periods nested, all with a `displayName` that already +// resolves label translation -> label -> default name for the requested locale. +// The client no longer derives any names, it only maps server period types to +// the client period type ids used for generating fixed periods. +import { useDataQuery } from '@dhis2/app-runtime' +import { useEffect, useMemo } from 'react' +import { + RP_CATEGORY_TO_FP_DEPENDENCIES, + SERVER_PT_TO_MULTI_CALENDAR_PT, +} from '../utils/enabledPeriodTypes.js' +import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' +import { FINANCIAL, filterPeriodTypesById } from '../utils/index.js' +import { getRelativePeriodsOptionsById } from '../utils/relativePeriods.js' + +const userLocaleQuery = { + userSettings: { + resource: 'userSettings', + params: { + key: ['keyUiLocale'], + }, + }, +} + +const v44Query = { + periodTypes: { + resource: 'configuration/dataOutputPeriodTypes', + params: ({ locale }) => ({ + fields: 'name,displayName,relativePeriods', + ...(locale && { locale }), + }), + }, + analysisRelativePeriod: { + resource: 'systemSettings/keyAnalysisRelativePeriod', + }, +} + +// The server nests relative periods under a single period type of a family +// (e.g. weekly relative periods under "Weekly" only), while the client groups +// relative periods in categories that are shown when any period type in the +// family is enabled +const RP_CATEGORY_TO_SERVER_PTS = { + ...RP_CATEGORY_TO_FP_DEPENDENCIES, + [FINANCIAL]: [ + 'FinancialFeb', + 'FinancialApril', + 'FinancialJuly', + 'FinancialAug', + 'FinancialSep', + 'FinancialOct', + 'FinancialNov', + ], +} + +const getRelativeCategoryId = (serverPtName) => + Object.keys(RP_CATEGORY_TO_SERVER_PTS).find((categoryId) => + RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(serverPtName) + ) + +const getRelativePeriodsFromPeriodType = (periodType) => + Object.entries(periodType.relativePeriods || {}).map( + ([id, { displayName }]) => ({ id, name: displayName }) + ) + +export const useV44DataOutputPeriodTypes = ({ enabled }) => { + const { data, error, refetch } = useDataQuery(v44Query, { lazy: true }) + const { refetch: fetchUserLocale } = useDataQuery(userLocaleQuery, { + lazy: true, + onComplete: ({ userSettings }) => + refetch({ locale: userSettings?.keyUiLocale }), + onError: () => refetch({}), + }) + + useEffect(() => { + if (enabled) { + fetchUserLocale() + } + }, [enabled, fetchUserLocale]) + + return useMemo(() => { + if (!enabled || error || !data?.periodTypes) { + return null + } + + const enabledTypes = data.periodTypes + + // relative period id -> name, used for overriding stored names + // (i.e. selected items in a saved visualization) + const metaData = enabledTypes + .flatMap(getRelativePeriodsFromPeriodType) + .reduce((acc, { id, name }) => { + acc[id] = { name } + return acc + }, {}) + + return { + version: 44, + enabledTypes, + analysisRelativePeriod: + data.analysisRelativePeriod?.keyAnalysisRelativePeriod || null, + metaData, + noEnabledTypes: enabledTypes.length === 0, + } + }, [enabled, data, error]) +} + +const getV44FixedOptions = ({ enabledTypes, periodsSettings }) => { + const clientOptionsById = getFixedPeriodsOptions(periodsSettings).reduce( + (acc, option) => { + acc[option.id] = option + return acc + }, + {} + ) + + return enabledTypes + .map((pt) => { + const option = + clientOptionsById[SERVER_PT_TO_MULTI_CALENDAR_PT[pt.name]] + + // server period types without client support for generating periods are skipped + return option && { ...option, name: pt.displayName || option.name } + }) + .filter(Boolean) +} + +const getV44RelativeOption = (categoryId, enabledTypes) => { + const familyTypes = enabledTypes.filter((pt) => + RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(pt.name) + ) + const typeWithRelativePeriods = familyTypes.find( + (pt) => Object.keys(pt.relativePeriods || {}).length + ) + + if (typeWithRelativePeriods) { + const periods = getRelativePeriodsFromPeriodType( + typeWithRelativePeriods + ) + + return { + id: categoryId, + name: typeWithRelativePeriods.displayName, + getPeriods: () => periods, + } + } + + // The server does not return relative periods for this category, either + // because the period type it nests them under is disabled (e.g. only + // WeeklySunday is enabled) or because there is no such period type (the + // financial year family). Fall back to the client relative periods. + const clientOption = getRelativePeriodsOptionsById(categoryId) + + return ( + clientOption && { + ...clientOption, + name: + familyTypes.length === 1 + ? familyTypes[0].displayName + : clientOption.name, + } + ) +} + +const getV44RelativeOptions = ({ enabledTypes }) => + enabledTypes + .map((pt) => getRelativeCategoryId(pt.name)) + .filter( + (categoryId, index, categoryIds) => + categoryId && categoryIds.indexOf(categoryId) === index + ) + .map((categoryId) => getV44RelativeOption(categoryId, enabledTypes)) + .filter(Boolean) + +export const getV44PeriodTypeOptions = ({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, +}) => { + const { enabledTypes } = enabledPeriodTypesData + + return { + fixedOptions: filterPeriodTypesById( + getV44FixedOptions({ enabledTypes, periodsSettings }), + excludedPeriodTypes + ), + relativeOptions: filterPeriodTypesById( + getV44RelativeOptions({ enabledTypes }), + excludedPeriodTypes + ), + } +} From 7be06d4647d4ca0bd3587b6e20d1c01e2d0bd8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Mon, 12 Oct 2026 11:55:53 +0200 Subject: [PATCH 02/11] fix: wip --- .../PeriodDimension/__tests__/v44.spec.js | 153 +++++++++++++++--- .../utils/enabledPeriodTypes.js | 21 +++ .../PeriodDimension/versions/v43.js | 21 +-- .../PeriodDimension/versions/v44.js | 121 ++++++++++---- 4 files changed, 248 insertions(+), 68 deletions(-) diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index f15e525a7..e19add529 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react' import { getV44PeriodTypeOptions, + sortPeriodTypes, useV44DataOutputPeriodTypes, } from '../versions/v44.js' @@ -39,9 +40,19 @@ const quarterlyNov = { name: 'QuarterlyNov', displayName: 'Quarterly Nov' } const financialApril = { name: 'FinancialApril', displayName: 'FY April' } const financialOct = { name: 'FinancialOct', displayName: 'FY October' } -const getOptions = (enabledTypes, excludedPeriodTypes = []) => +const DEFAULT_START_TYPES = { WEEKLY: 'Weekly', FINANCIAL: 'FinancialOct' } + +const getOptions = ( + enabledTypes, + excludedPeriodTypes = [], + relativePeriodStartTypes = DEFAULT_START_TYPES +) => getV44PeriodTypeOptions({ - enabledPeriodTypesData: { version: 44, enabledTypes }, + enabledPeriodTypesData: { + version: 44, + enabledTypes, + relativePeriodStartTypes, + }, periodsSettings: { calendar: 'gregory', locale: 'en' }, excludedPeriodTypes, }) @@ -54,7 +65,7 @@ const summarize = (options) => })) describe('v44 period type options', () => { - it('uses server displayName for fixed period types, in server order', () => { + it('uses server displayName for fixed period types', () => { const { fixedOptions } = getOptions([ daily, weekly, @@ -116,9 +127,37 @@ describe('v44 period type options', () => { ]) }) - it('falls back to client relative periods when the server does not nest them', () => { - // Weekly disabled: server nests weekly relative periods under Weekly only - const { relativeOptions } = getOptions([weeklySunday]) + it('names the weekly category after the weekly start variant from the system setting', () => { + const { relativeOptions } = getOptions([weekly, weeklySunday], [], { + ...DEFAULT_START_TYPES, + WEEKLY: 'WeeklySunday', + }) + + expect(summarize(relativeOptions)).toEqual([ + { + id: 'WEEKLY', + name: 'EPI Week', + // labels of the weekly relative periods are nested under Weekly + periods: [ + 'THIS_WEEK:Cette semaine', + 'LAST_WEEK:Semaine dernière', + ], + }, + ]) + }) + + it('hides weekly relative periods when the weekly start variant is disabled', () => { + const { relativeOptions } = getOptions([weeklySunday, monthly]) + + expect(relativeOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + }) + + it('falls back to client weekly relative periods when Weekly is disabled', () => { + // the server only nests weekly relative periods under Weekly + const { relativeOptions } = getOptions([weeklySunday], [], { + ...DEFAULT_START_TYPES, + WEEKLY: 'WeeklySunday', + }) expect(relativeOptions).toHaveLength(1) expect(relativeOptions[0].id).toBe('WEEKLY') @@ -128,23 +167,26 @@ describe('v44 period type options', () => { ) }) - it('shows financial year relative periods when any financial type is enabled', () => { - const single = getOptions([financialOct]).relativeOptions - expect(single.map(({ id, name }) => ({ id, name }))).toEqual([ + it('shows financial year relative periods for the financial year start variant from the system setting', () => { + const { relativeOptions } = getOptions([financialApril, financialOct]) + + expect(relativeOptions.map(({ id, name }) => ({ id, name }))).toEqual([ { id: 'FINANCIAL', name: 'FY October' }, ]) - expect(single[0].getPeriods().map(({ id }) => id)).toContain( + // the server does not return financial year relative periods + expect(relativeOptions[0].getPeriods().map(({ id }) => id)).toContain( 'THIS_FINANCIAL_YEAR' ) + }) - // ambiguous family label, use the client category name - const multiple = getOptions([ - financialApril, - financialOct, - ]).relativeOptions - expect(multiple.map(({ id, name }) => ({ id, name }))).toEqual([ - { id: 'FINANCIAL', name: 'Financial Years' }, - ]) + it('hides financial year relative periods when the start variant is disabled or not set', () => { + expect(getOptions([financialApril]).relativeOptions).toEqual([]) + expect( + getOptions([financialOct], [], { + ...DEFAULT_START_TYPES, + FINANCIAL: null, + }).relativeOptions + ).toEqual([]) }) it('applies excludedPeriodTypes to both fixed and relative options', () => { @@ -165,6 +207,40 @@ describe('v44 period type options', () => { }) }) +describe('sortPeriodTypes', () => { + it('sorts period types naturally from days to financial years', () => { + const shuffled = [ + ['FinancialOct', 365], + ['Monthly', 30], + ['WeeklySunday', 7], + ['Yearly', 365], + ['QuarterlyNov', 91], + ['FinancialApril', 365], + ['Daily', 1], + ['SixMonthly', 182], + ['Quarterly', 91], + ['Weekly', 7], + ['BiWeekly', 14], + ['WeeklyWednesday', 7], + ].map(([name, frequencyOrder]) => ({ name, frequencyOrder })) + + expect(sortPeriodTypes(shuffled).map(({ name }) => name)).toEqual([ + 'Daily', + 'Weekly', + 'WeeklyWednesday', + 'WeeklySunday', + 'BiWeekly', + 'Monthly', + 'Quarterly', + 'QuarterlyNov', + 'SixMonthly', + 'Yearly', + 'FinancialApril', + 'FinancialOct', + ]) + }) +}) + describe('useV44DataOutputPeriodTypes', () => { const mockQueryState = {} @@ -217,6 +293,10 @@ describe('useV44DataOutputPeriodTypes', () => { analysisRelativePeriod: { keyAnalysisRelativePeriod: 'LAST_12_MONTHS', }, + weeklyStart: { analyticsWeeklyStart: 'WEEKLY_SUNDAY' }, + financialYearStart: { + analyticsFinancialYearStart: 'FINANCIAL_YEAR_APRIL', + }, } const { result } = renderHook(() => @@ -227,6 +307,10 @@ describe('useV44DataOutputPeriodTypes', () => { version: 44, enabledTypes: [daily, monthly], analysisRelativePeriod: 'LAST_12_MONTHS', + relativePeriodStartTypes: { + WEEKLY: 'WeeklySunday', + FINANCIAL: 'FinancialApril', + }, noEnabledTypes: false, metaData: { TODAY: { name: "Aujourd'hui" }, @@ -238,6 +322,39 @@ describe('useV44DataOutputPeriodTypes', () => { }) }) + it('defaults the weekly start to Weekly when the setting is missing', () => { + mockQueryState.v44Data = { periodTypes: [weekly] } + + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current.relativePeriodStartTypes).toEqual({ + WEEKLY: 'Weekly', + FINANCIAL: null, + }) + }) + + it('returns enabled types in natural order', () => { + mockQueryState.v44Data = { + periodTypes: [ + { ...financialOct, frequencyOrder: 365 }, + { ...monthly, frequencyOrder: 30 }, + { ...daily, frequencyOrder: 1 }, + ], + } + + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current.enabledTypes.map(({ name }) => name)).toEqual([ + 'Daily', + 'Monthly', + 'FinancialOct', + ]) + }) + it('flags when no period types are enabled', () => { mockQueryState.v44Data = { periodTypes: [] } diff --git a/src/components/PeriodDimension/utils/enabledPeriodTypes.js b/src/components/PeriodDimension/utils/enabledPeriodTypes.js index 6a104d912..2737949a7 100644 --- a/src/components/PeriodDimension/utils/enabledPeriodTypes.js +++ b/src/components/PeriodDimension/utils/enabledPeriodTypes.js @@ -25,6 +25,27 @@ export const SERVER_PT_TO_MULTI_CALENDAR_PT = { FinancialNov: 'FYNOV', } +// Mapping from the analyticsFinancialYearStart system setting to server period type names +export const FY_SETTING_TO_SERVER_PT = { + FINANCIAL_YEAR_FEBRUARY: 'FinancialFeb', + FINANCIAL_YEAR_APRIL: 'FinancialApril', + FINANCIAL_YEAR_JULY: 'FinancialJuly', + FINANCIAL_YEAR_AUGUST: 'FinancialAug', + FINANCIAL_YEAR_SEPTEMBER: 'FinancialSep', + FINANCIAL_YEAR_OCTOBER: 'FinancialOct', + FINANCIAL_YEAR_NOVEMBER: 'FinancialNov', +} + +// Mapping from the analyticsWeeklyStart system setting to server period type names +export const WEEKLY_START_TO_SERVER_PT = { + WEEKLY: 'Weekly', + WEEKLY_WEDNESDAY: 'WeeklyWednesday', + WEEKLY_THURSDAY: 'WeeklyThursday', + WEEKLY_FRIDAY: 'WeeklyFriday', + WEEKLY_SATURDAY: 'WeeklySaturday', + WEEKLY_SUNDAY: 'WeeklySunday', +} + // Mapping from relative period categories to their corresponding fixed period types export const RP_CATEGORY_TO_FP_DEPENDENCIES = { DAILY: ['Daily'], diff --git a/src/components/PeriodDimension/versions/v43.js b/src/components/PeriodDimension/versions/v43.js index 7f1109379..0747baec6 100644 --- a/src/components/PeriodDimension/versions/v43.js +++ b/src/components/PeriodDimension/versions/v43.js @@ -8,6 +8,8 @@ import { useEffect, useMemo } from 'react' import { RP_CATEGORY_TO_FP_DEPENDENCIES, SERVER_PT_TO_MULTI_CALENDAR_PT, + FY_SETTING_TO_SERVER_PT, + WEEKLY_START_TO_SERVER_PT, applyPeriodNameOverrides, filterEnabledFixedPeriodTypes, } from '../utils/enabledPeriodTypes.js' @@ -29,25 +31,6 @@ const v43Query = { }, } -const FY_SETTING_TO_SERVER_PT = { - FINANCIAL_YEAR_FEBRUARY: 'FinancialFeb', - FINANCIAL_YEAR_APRIL: 'FinancialApril', - FINANCIAL_YEAR_JULY: 'FinancialJuly', - FINANCIAL_YEAR_AUGUST: 'FinancialAug', - FINANCIAL_YEAR_SEPTEMBER: 'FinancialSep', - FINANCIAL_YEAR_OCTOBER: 'FinancialOct', - FINANCIAL_YEAR_NOVEMBER: 'FinancialNov', -} - -const WEEKLY_START_TO_SERVER_PT = { - WEEKLY: 'Weekly', - WEEKLY_WEDNESDAY: 'WeeklyWednesday', - WEEKLY_THURSDAY: 'WeeklyThursday', - WEEKLY_FRIDAY: 'WeeklyFriday', - WEEKLY_SATURDAY: 'WeeklySaturday', - WEEKLY_SUNDAY: 'WeeklySunday', -} - export const useV43DataOutputPeriodTypes = ({ enabled }) => { const { data: v43Data, diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index dff0d4d3c..7585b3e07 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -3,14 +3,19 @@ // resolves label translation -> label -> default name for the requested locale. // The client no longer derives any names, it only maps server period types to // the client period type ids used for generating fixed periods. +// Weekly and financial year relative periods have one set of labels; the +// analyticsWeeklyStart and analyticsFinancialYearStart system settings decide +// which start variant they refer to. import { useDataQuery } from '@dhis2/app-runtime' import { useEffect, useMemo } from 'react' import { + FY_SETTING_TO_SERVER_PT, RP_CATEGORY_TO_FP_DEPENDENCIES, SERVER_PT_TO_MULTI_CALENDAR_PT, + WEEKLY_START_TO_SERVER_PT, } from '../utils/enabledPeriodTypes.js' import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' -import { FINANCIAL, filterPeriodTypesById } from '../utils/index.js' +import { FINANCIAL, WEEKLY, filterPeriodTypesById } from '../utils/index.js' import { getRelativePeriodsOptionsById } from '../utils/relativePeriods.js' const userLocaleQuery = { @@ -26,13 +31,19 @@ const v44Query = { periodTypes: { resource: 'configuration/dataOutputPeriodTypes', params: ({ locale }) => ({ - fields: 'name,displayName,relativePeriods', + fields: 'name,displayName,frequencyOrder,relativePeriods', ...(locale && { locale }), }), }, analysisRelativePeriod: { resource: 'systemSettings/keyAnalysisRelativePeriod', }, + weeklyStart: { + resource: 'systemSettings/analyticsWeeklyStart', + }, + financialYearStart: { + resource: 'systemSettings/analyticsFinancialYearStart', + }, } // The server nests relative periods under a single period type of a family @@ -57,6 +68,23 @@ const getRelativeCategoryId = (serverPtName) => RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(serverPtName) ) +// Natural order (days -> financial years), not relying on the response order. +// frequencyOrder ties (e.g. weekly variants, yearly and financial years) are +// broken by the client order of period types. +const CLIENT_PT_ORDER = Object.keys(SERVER_PT_TO_MULTI_CALENDAR_PT) + +const getClientOrder = (name) => { + const index = CLIENT_PT_ORDER.indexOf(name) + return index === -1 ? CLIENT_PT_ORDER.length : index +} + +export const sortPeriodTypes = (periodTypes) => + [...periodTypes].sort( + (a, b) => + (a.frequencyOrder ?? Infinity) - (b.frequencyOrder ?? Infinity) || + getClientOrder(a.name) - getClientOrder(b.name) + ) + const getRelativePeriodsFromPeriodType = (periodType) => Object.entries(periodType.relativePeriods || {}).map( ([id, { displayName }]) => ({ id, name: displayName }) @@ -82,7 +110,7 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { return null } - const enabledTypes = data.periodTypes + const enabledTypes = sortPeriodTypes(data.periodTypes) // relative period id -> name, used for overriding stored names // (i.e. selected items in a saved visualization) @@ -98,6 +126,18 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { enabledTypes, analysisRelativePeriod: data.analysisRelativePeriod?.keyAnalysisRelativePeriod || null, + // server period type names of the start variants the weekly and + // financial year relative periods refer to + relativePeriodStartTypes: { + [WEEKLY]: + WEEKLY_START_TO_SERVER_PT[ + data.weeklyStart?.analyticsWeeklyStart + ] || WEEKLY_START_TO_SERVER_PT.WEEKLY, + [FINANCIAL]: + FY_SETTING_TO_SERVER_PT[ + data.financialYearStart?.analyticsFinancialYearStart + ] || null, + }, metaData, noEnabledTypes: enabledTypes.length === 0, } @@ -124,51 +164,70 @@ const getV44FixedOptions = ({ enabledTypes, periodsSettings }) => { .filter(Boolean) } -const getV44RelativeOption = (categoryId, enabledTypes) => { +const getV44RelativeOption = ( + categoryId, + enabledTypes, + relativePeriodStartTypes +) => { const familyTypes = enabledTypes.filter((pt) => RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(pt.name) ) + + // Weekly and financial year relative periods are only available when the + // start variant selected in the system settings is enabled + const startTypeName = relativePeriodStartTypes?.[categoryId] + const startType = + startTypeName !== undefined + ? familyTypes.find((pt) => pt.name === startTypeName) + : null + + if (startTypeName !== undefined && !startType) { + return null + } + const typeWithRelativePeriods = familyTypes.find( (pt) => Object.keys(pt.relativePeriods || {}).length ) - if (typeWithRelativePeriods) { - const periods = getRelativePeriodsFromPeriodType( - typeWithRelativePeriods - ) + // The server does not return relative periods for this category when the + // period type it nests them under is disabled (e.g. weekly relative periods + // are nested under Weekly while only WeeklySunday is enabled) or when there + // is no such period type (financial year relative periods). + // Fall back to the client relative periods. + const clientOption = getRelativePeriodsOptionsById(categoryId) - return { - id: categoryId, - name: typeWithRelativePeriods.displayName, - getPeriods: () => periods, - } + if (!typeWithRelativePeriods && !clientOption) { + return null } - // The server does not return relative periods for this category, either - // because the period type it nests them under is disabled (e.g. only - // WeeklySunday is enabled) or because there is no such period type (the - // financial year family). Fall back to the client relative periods. - const clientOption = getRelativePeriodsOptionsById(categoryId) + const periods = typeWithRelativePeriods + ? getRelativePeriodsFromPeriodType(typeWithRelativePeriods) + : clientOption.getPeriods() - return ( - clientOption && { - ...clientOption, - name: - familyTypes.length === 1 - ? familyTypes[0].displayName - : clientOption.name, - } - ) + return { + id: categoryId, + name: + startType?.displayName || + typeWithRelativePeriods?.displayName || + clientOption.name, + getPeriods: () => periods, + } } -const getV44RelativeOptions = ({ enabledTypes }) => +const getV44RelativeOptions = ({ enabledTypes, relativePeriodStartTypes }) => enabledTypes .map((pt) => getRelativeCategoryId(pt.name)) .filter( (categoryId, index, categoryIds) => categoryId && categoryIds.indexOf(categoryId) === index ) - .map((categoryId) => getV44RelativeOption(categoryId, enabledTypes)) + .map((categoryId) => + getV44RelativeOption( + categoryId, + enabledTypes, + relativePeriodStartTypes + ) + ) .filter(Boolean) export const getV44PeriodTypeOptions = ({ @@ -176,7 +235,7 @@ export const getV44PeriodTypeOptions = ({ periodsSettings, excludedPeriodTypes, }) => { - const { enabledTypes } = enabledPeriodTypesData + const { enabledTypes, relativePeriodStartTypes } = enabledPeriodTypesData return { fixedOptions: filterPeriodTypesById( @@ -184,7 +243,7 @@ export const getV44PeriodTypeOptions = ({ excludedPeriodTypes ), relativeOptions: filterPeriodTypesById( - getV44RelativeOptions({ enabledTypes }), + getV44RelativeOptions({ enabledTypes, relativePeriodStartTypes }), excludedPeriodTypes ), } From 342f6b909b213ebad1e9103f35fad4d460603232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Fri, 16 Oct 2026 12:04:30 +0200 Subject: [PATCH 03/11] fix: relative periods now nested under all variants --- .../PeriodDimension/__tests__/v44.spec.js | 148 ++++++++++-------- .../PeriodDimension/versions/v44.js | 68 ++------ 2 files changed, 101 insertions(+), 115 deletions(-) diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index e19add529..08314a6f6 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -1,7 +1,6 @@ import { renderHook } from '@testing-library/react' import { getV44PeriodTypeOptions, - sortPeriodTypes, useV44DataOutputPeriodTypes, } from '../versions/v44.js' @@ -18,15 +17,22 @@ const daily = { YESTERDAY: { displayName: 'Hier' }, }, } +// relative periods are nested under every period type of the family, +// with the same labels +const weeklyRelativePeriods = { + THIS_WEEK: { displayName: 'Cette semaine' }, + LAST_WEEK: { displayName: 'Semaine dernière' }, +} const weekly = { name: 'Weekly', displayName: 'Hebdomadaire', - relativePeriods: { - THIS_WEEK: { displayName: 'Cette semaine' }, - LAST_WEEK: { displayName: 'Semaine dernière' }, - }, + relativePeriods: weeklyRelativePeriods, +} +const weeklySunday = { + name: 'WeeklySunday', + displayName: 'EPI Week', + relativePeriods: weeklyRelativePeriods, } -const weeklySunday = { name: 'WeeklySunday', displayName: 'EPI Week' } const monthly = { name: 'Monthly', displayName: 'Mois', @@ -36,9 +42,33 @@ const monthly = { MONTHS_LAST_YEAR: { displayName: "Mois de l'an dernier" }, }, } -const quarterlyNov = { name: 'QuarterlyNov', displayName: 'Quarterly Nov' } -const financialApril = { name: 'FinancialApril', displayName: 'FY April' } -const financialOct = { name: 'FinancialOct', displayName: 'FY October' } +const quarterlyRelativePeriods = { + THIS_QUARTER: { displayName: 'Ce trimestre' }, +} +const quarterly = { + name: 'Quarterly', + displayName: 'Trimestriel', + relativePeriods: quarterlyRelativePeriods, +} +const quarterlyNov = { + name: 'QuarterlyNov', + displayName: 'Quarterly Nov', + relativePeriods: quarterlyRelativePeriods, +} +const financialRelativePeriods = { + THIS_FINANCIAL_YEAR: { displayName: 'Cet exercice' }, + LAST_FINANCIAL_YEAR: { displayName: 'Exercice précédent' }, +} +const financialApril = { + name: 'FinancialApril', + displayName: 'FY April', + relativePeriods: financialRelativePeriods, +} +const financialOct = { + name: 'FinancialOct', + displayName: 'FY October', + relativePeriods: financialRelativePeriods, +} const DEFAULT_START_TYPES = { WEEKLY: 'Weekly', FINANCIAL: 'FinancialOct' } @@ -137,7 +167,6 @@ describe('v44 period type options', () => { { id: 'WEEKLY', name: 'EPI Week', - // labels of the weekly relative periods are nested under Weekly periods: [ 'THIS_WEEK:Cette semaine', 'LAST_WEEK:Semaine dernière', @@ -152,31 +181,60 @@ describe('v44 period type options', () => { expect(relativeOptions.map(({ id }) => id)).toEqual(['MONTHLY']) }) - it('falls back to client weekly relative periods when Weekly is disabled', () => { - // the server only nests weekly relative periods under Weekly + it('shows weekly relative periods when only the weekly start variant is enabled', () => { const { relativeOptions } = getOptions([weeklySunday], [], { ...DEFAULT_START_TYPES, WEEKLY: 'WeeklySunday', }) - expect(relativeOptions).toHaveLength(1) - expect(relativeOptions[0].id).toBe('WEEKLY') - expect(relativeOptions[0].name).toBe('EPI Week') - expect(relativeOptions[0].getPeriods().map(({ id }) => id)).toContain( - 'THIS_WEEK' - ) + expect(summarize(relativeOptions)).toEqual([ + { + id: 'WEEKLY', + name: 'EPI Week', + periods: [ + 'THIS_WEEK:Cette semaine', + 'LAST_WEEK:Semaine dernière', + ], + }, + ]) }) it('shows financial year relative periods for the financial year start variant from the system setting', () => { const { relativeOptions } = getOptions([financialApril, financialOct]) - expect(relativeOptions.map(({ id, name }) => ({ id, name }))).toEqual([ - { id: 'FINANCIAL', name: 'FY October' }, + expect(summarize(relativeOptions)).toEqual([ + { + id: 'FINANCIAL', + name: 'FY October', + periods: [ + 'THIS_FINANCIAL_YEAR:Cet exercice', + 'LAST_FINANCIAL_YEAR:Exercice précédent', + ], + }, ]) - // the server does not return financial year relative periods - expect(relativeOptions[0].getPeriods().map(({ id }) => id)).toContain( - 'THIS_FINANCIAL_YEAR' - ) + }) + + it('shows one quarterly category named after the first enabled quarterly type', () => { + expect( + summarize(getOptions([quarterly, quarterlyNov]).relativeOptions) + ).toEqual([ + { + id: 'QUARTERLY', + name: 'Trimestriel', + periods: ['THIS_QUARTER:Ce trimestre'], + }, + ]) + expect( + getOptions([quarterlyNov]).relativeOptions.map(({ name }) => name) + ).toEqual(['Quarterly Nov']) + }) + + it('hides a category when the server returns no relative periods for it', () => { + const { relativeOptions } = getOptions([ + { ...monthly, relativePeriods: {} }, + ]) + + expect(relativeOptions).toEqual([]) }) it('hides financial year relative periods when the start variant is disabled or not set', () => { @@ -207,40 +265,6 @@ describe('v44 period type options', () => { }) }) -describe('sortPeriodTypes', () => { - it('sorts period types naturally from days to financial years', () => { - const shuffled = [ - ['FinancialOct', 365], - ['Monthly', 30], - ['WeeklySunday', 7], - ['Yearly', 365], - ['QuarterlyNov', 91], - ['FinancialApril', 365], - ['Daily', 1], - ['SixMonthly', 182], - ['Quarterly', 91], - ['Weekly', 7], - ['BiWeekly', 14], - ['WeeklyWednesday', 7], - ].map(([name, frequencyOrder]) => ({ name, frequencyOrder })) - - expect(sortPeriodTypes(shuffled).map(({ name }) => name)).toEqual([ - 'Daily', - 'Weekly', - 'WeeklyWednesday', - 'WeeklySunday', - 'BiWeekly', - 'Monthly', - 'Quarterly', - 'QuarterlyNov', - 'SixMonthly', - 'Yearly', - 'FinancialApril', - 'FinancialOct', - ]) - }) -}) - describe('useV44DataOutputPeriodTypes', () => { const mockQueryState = {} @@ -335,13 +359,9 @@ describe('useV44DataOutputPeriodTypes', () => { }) }) - it('returns enabled types in natural order', () => { + it('keeps the server order of period types', () => { mockQueryState.v44Data = { - periodTypes: [ - { ...financialOct, frequencyOrder: 365 }, - { ...monthly, frequencyOrder: 30 }, - { ...daily, frequencyOrder: 1 }, - ], + periodTypes: [daily, monthly, financialOct], } const { result } = renderHook(() => diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index 7585b3e07..7509bd550 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -16,7 +16,6 @@ import { } from '../utils/enabledPeriodTypes.js' import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' import { FINANCIAL, WEEKLY, filterPeriodTypesById } from '../utils/index.js' -import { getRelativePeriodsOptionsById } from '../utils/relativePeriods.js' const userLocaleQuery = { userSettings: { @@ -31,7 +30,7 @@ const v44Query = { periodTypes: { resource: 'configuration/dataOutputPeriodTypes', params: ({ locale }) => ({ - fields: 'name,displayName,frequencyOrder,relativePeriods', + fields: 'name,displayName,relativePeriods', ...(locale && { locale }), }), }, @@ -46,10 +45,9 @@ const v44Query = { }, } -// The server nests relative periods under a single period type of a family -// (e.g. weekly relative periods under "Weekly" only), while the client groups -// relative periods in categories that are shown when any period type in the -// family is enabled +// Server period types grouped by the relative period category they belong to. +// The category ids (e.g. WEEKLY, FINANCIAL) are part of the public API +// (excludedPeriodTypes, default selection) and the server has no equivalent. const RP_CATEGORY_TO_SERVER_PTS = { ...RP_CATEGORY_TO_FP_DEPENDENCIES, [FINANCIAL]: [ @@ -68,23 +66,6 @@ const getRelativeCategoryId = (serverPtName) => RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(serverPtName) ) -// Natural order (days -> financial years), not relying on the response order. -// frequencyOrder ties (e.g. weekly variants, yearly and financial years) are -// broken by the client order of period types. -const CLIENT_PT_ORDER = Object.keys(SERVER_PT_TO_MULTI_CALENDAR_PT) - -const getClientOrder = (name) => { - const index = CLIENT_PT_ORDER.indexOf(name) - return index === -1 ? CLIENT_PT_ORDER.length : index -} - -export const sortPeriodTypes = (periodTypes) => - [...periodTypes].sort( - (a, b) => - (a.frequencyOrder ?? Infinity) - (b.frequencyOrder ?? Infinity) || - getClientOrder(a.name) - getClientOrder(b.name) - ) - const getRelativePeriodsFromPeriodType = (periodType) => Object.entries(periodType.relativePeriods || {}).map( ([id, { displayName }]) => ({ id, name: displayName }) @@ -110,7 +91,8 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { return null } - const enabledTypes = sortPeriodTypes(data.periodTypes) + // the server returns period types in natural order (days -> financial years) + const enabledTypes = data.periodTypes // relative period id -> name, used for overriding stored names // (i.e. selected items in a saved visualization) @@ -173,43 +155,27 @@ const getV44RelativeOption = ( RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(pt.name) ) - // Weekly and financial year relative periods are only available when the - // start variant selected in the system settings is enabled + // The server nests the relative periods of a family under every period + // type of the family. Weekly and financial year relative periods refer to + // the start variant selected in the system settings, so they are only + // available when that variant is enabled. const startTypeName = relativePeriodStartTypes?.[categoryId] - const startType = + const sourceType = startTypeName !== undefined ? familyTypes.find((pt) => pt.name === startTypeName) - : null + : familyTypes[0] - if (startTypeName !== undefined && !startType) { - return null - } - - const typeWithRelativePeriods = familyTypes.find( - (pt) => Object.keys(pt.relativePeriods || {}).length - ) + const periods = sourceType + ? getRelativePeriodsFromPeriodType(sourceType) + : [] - // The server does not return relative periods for this category when the - // period type it nests them under is disabled (e.g. weekly relative periods - // are nested under Weekly while only WeeklySunday is enabled) or when there - // is no such period type (financial year relative periods). - // Fall back to the client relative periods. - const clientOption = getRelativePeriodsOptionsById(categoryId) - - if (!typeWithRelativePeriods && !clientOption) { + if (!periods.length) { return null } - const periods = typeWithRelativePeriods - ? getRelativePeriodsFromPeriodType(typeWithRelativePeriods) - : clientOption.getPeriods() - return { id: categoryId, - name: - startType?.displayName || - typeWithRelativePeriods?.displayName || - clientOption.name, + name: sourceType.displayName, getPeriods: () => periods, } } From f5a80615808462b83d817749e42f9e4095ea9303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Fri, 16 Oct 2026 13:50:30 +0200 Subject: [PATCH 04/11] test: add storybook and fix broken tests --- src/__demo__/PeriodDimension.stories.js | 246 +++++++-- src/__demo__/data/periodTypes.v44.js | 472 ++++++++++++++++++ .../PeriodDimension/__tests__/v44.spec.js | 42 +- .../PeriodDimension/versions/v44.js | 25 +- 4 files changed, 740 insertions(+), 45 deletions(-) create mode 100644 src/__demo__/data/periodTypes.v44.js diff --git a/src/__demo__/PeriodDimension.stories.js b/src/__demo__/PeriodDimension.stories.js index 9c70f91d6..699778141 100644 --- a/src/__demo__/PeriodDimension.stories.js +++ b/src/__demo__/PeriodDimension.stories.js @@ -1,4 +1,4 @@ -import { DataProvider } from '@dhis2/app-runtime' +import { CustomDataProvider } from '@dhis2/app-runtime' import { ConfigProvider } from '@dhis2/app-service-config' import React from 'react' import PeriodDimension from '../components/PeriodDimension/PeriodDimension.js' @@ -13,32 +13,83 @@ import { BIWEEKLY, BIMONTHLY, } from '../components/PeriodDimension/utils/index.js' +import { periodTypesV44 } from './data/periodTypes.v44.js' -const Wrapper = (story) => ( - - - {story()} - - -) +// The period selector has one implementation per server version (42, 43, 44), +// selected by serverVersion.minor. The server responses are mocked. +const withServer = ({ minor, data }) => + function MockServer(Story) { + return ( + + + + + + ) + } + +// Mimics the v44 server: relative period labels are stored per relative +// period and returned under every period type that has the relative period +const applyV44Labels = ( + periodTypes, + { periodTypeLabels, relativePeriodLabels } +) => + periodTypes.map((pt) => ({ + ...pt, + displayName: periodTypeLabels[pt.name] || pt.displayName, + relativePeriods: Object.fromEntries( + Object.entries(pt.relativePeriods).map(([id, rp]) => [ + id, + { displayName: relativePeriodLabels[id] || rp.displayName }, + ]) + ), + })) + +const v44Data = ({ + // the server returns all period types when none are configured + enabledPeriodTypes = periodTypesV44.map(({ name }) => name), + periodTypeLabels = {}, + relativePeriodLabels = {}, + uiLocale = 'en', + weeklyStart = 'WEEKLY', + financialYearStart = 'FINANCIAL_YEAR_OCTOBER', + analysisRelativePeriod = 'LAST_12_MONTHS', +} = {}) => ({ + userSettings: { keyUiLocale: uiLocale }, + 'configuration/dataOutputPeriodTypes': applyV44Labels( + periodTypesV44.filter(({ name }) => enabledPeriodTypes.includes(name)), + { periodTypeLabels, relativePeriodLabels } + ), + 'systemSettings/keyAnalysisRelativePeriod': { + keyAnalysisRelativePeriod: analysisRelativePeriod, + }, + 'systemSettings/analyticsWeeklyStart': { + analyticsWeeklyStart: weeklyStart, + }, + 'systemSettings/analyticsFinancialYearStart': { + analyticsFinancialYearStart: financialYearStart, + }, +}) const selectedPeriods = [{ id: 'LAST_12_MONTHS', name: 'Last 12 months' }] export default { title: 'PeriodDimension', - decorators: [Wrapper], + decorators: [withServer({ minor: 44, data: v44Data() })], } export const NoneSelected = () => { return console.log(selected)} /> } -NoneSelected.story = { - name: 'None selected', -} +NoneSelected.storyName = 'None selected' export const OneSelected = () => { return ( @@ -49,9 +100,7 @@ export const OneSelected = () => { ) } -OneSelected.story = { - name: 'One selected', -} +OneSelected.storyName = 'One selected' export const MonthlyExcluded = () => { return ( @@ -62,9 +111,7 @@ export const MonthlyExcluded = () => { ) } -MonthlyExcluded.story = { - name: 'Monthly excluded', -} +MonthlyExcluded.storyName = 'Monthly excluded' export const WeeklyExcluded = () => { return ( @@ -81,9 +128,7 @@ export const WeeklyExcluded = () => { ) } -WeeklyExcluded.story = { - name: 'Weekly excluded', -} +WeeklyExcluded.storyName = 'Weekly excluded' export const AllBelowQuarterlyExcluded = () => { return ( @@ -104,9 +149,7 @@ export const AllBelowQuarterlyExcluded = () => { ) } -AllBelowQuarterlyExcluded.story = { - name: 'All below Quarterly excluded', -} +AllBelowQuarterlyExcluded.storyName = 'All below Quarterly excluded' export const UsingRightFooter = () => { return ( @@ -127,6 +170,151 @@ export const UsingRightFooter = () => { ) } -UsingRightFooter.story = { - name: 'Using right footer', +UsingRightFooter.storyName = 'Using right footer' + +export const V44CustomLabels = () => { + return ( + console.log(selected)} + /> + ) } + +V44CustomLabels.storyName = + 'v44: custom labels, only Weekly Sunday and Financial April enabled' +V44CustomLabels.decorators = [ + withServer({ + minor: 44, + data: v44Data({ + enabledPeriodTypes: [ + 'Daily', + 'WeeklySunday', + 'Monthly', + 'Quarterly', + 'Yearly', + 'FinancialApril', + ], + weeklyStart: 'WEEKLY_SUNDAY', + financialYearStart: 'FINANCIAL_YEAR_APRIL', + // opens the weekly relative periods + analysisRelativePeriod: 'LAST_4_WEEKS', + periodTypeLabels: { + WeeklySunday: 'EPI week', + FinancialApril: 'Fiscal year', + }, + relativePeriodLabels: { + THIS_WEEK: 'This EPI week', + LAST_WEEK: 'Last EPI week', + LAST_4_WEEKS: 'Last 4 EPI weeks', + LAST_12_WEEKS: 'Last 12 EPI weeks', + LAST_52_WEEKS: 'Last 52 EPI weeks', + WEEKS_THIS_YEAR: 'EPI weeks this year', + THIS_FINANCIAL_YEAR: 'This fiscal year', + LAST_FINANCIAL_YEAR: 'Last fiscal year', + LAST_5_FINANCIAL_YEARS: 'Last 5 fiscal years', + LAST_10_FINANCIAL_YEARS: 'Last 10 fiscal years', + }, + }), + }), +] + +export const V44Translated = () => { + return ( + console.log(selected)} + /> + ) +} + +V44Translated.storyName = 'v44: label translations (ui locale fr)' +V44Translated.decorators = [ + withServer({ + minor: 44, + data: v44Data({ + uiLocale: 'fr', + enabledPeriodTypes: ['Daily', 'Weekly', 'Monthly', 'Yearly'], + periodTypeLabels: { + Daily: 'Quotidien', + Weekly: 'Hebdomadaire', + Monthly: 'Mensuel', + Yearly: 'Annuel', + }, + relativePeriodLabels: { + TODAY: "Aujourd'hui", + YESTERDAY: 'Hier', + THIS_WEEK: 'Cette semaine', + LAST_WEEK: 'La semaine dernière', + THIS_MONTH: 'Ce mois-ci', + LAST_MONTH: 'Le mois dernier', + LAST_3_MONTHS: '3 derniers mois', + LAST_6_MONTHS: '6 derniers mois', + LAST_12_MONTHS: '12 derniers mois', + THIS_YEAR: 'Cette année', + LAST_YEAR: "L'année dernière", + }, + }), + }), +] + +export const V44WeeklyStartDisabled = () => { + return console.log(selected)} /> +} + +V44WeeklyStartDisabled.storyName = + 'v44: weekly start Sunday, but only Weekly enabled (no weekly relative periods)' +V44WeeklyStartDisabled.decorators = [ + withServer({ + minor: 44, + data: v44Data({ + enabledPeriodTypes: ['Weekly', 'Monthly'], + weeklyStart: 'WEEKLY_SUNDAY', + }), + }), +] + +export const V43Labels = () => { + return console.log(selected)} /> +} + +V43Labels.storyName = + 'v43: period type labels, relative period names derived from them' +V43Labels.decorators = [ + withServer({ + minor: 43, + data: { + userSettings: { keyUiLocale: 'en' }, + 'configuration/dataOutputPeriodTypes': [ + { name: 'Daily' }, + { name: 'WeeklySunday', displayLabel: 'EPI week' }, + { name: 'Monthly' }, + { name: 'Quarterly' }, + { name: 'Yearly' }, + { name: 'FinancialApril', displayLabel: 'Fiscal year' }, + ], + 'systemSettings/keyAnalysisRelativePeriod': { + keyAnalysisRelativePeriod: 'LAST_12_MONTHS', + }, + 'systemSettings/analyticsWeeklyStart': { + analyticsWeeklyStart: 'WEEKLY_SUNDAY', + }, + 'systemSettings/analyticsFinancialYearStart': { + analyticsFinancialYearStart: 'FINANCIAL_YEAR_APRIL', + }, + }, + }), +] + +export const V42 = () => { + return console.log(selected)} /> +} + +V42.storyName = 'v42: client period types and relative periods' +V42.decorators = [ + withServer({ + minor: 42, + data: { userSettings: { keyUiLocale: 'en' } }, + }), +] diff --git a/src/__demo__/data/periodTypes.v44.js b/src/__demo__/data/periodTypes.v44.js new file mode 100644 index 000000000..fc0468060 --- /dev/null +++ b/src/__demo__/data/periodTypes.v44.js @@ -0,0 +1,472 @@ +// All period types from /api/periodTypes (v44) with default names, in server order. +// Fields as requested by the v44 period selector: name,displayName,relativePeriods +export const periodTypesV44 = [ + { + name: 'Daily', + displayName: 'Daily', + relativePeriods: { + TODAY: { + displayName: 'Today', + }, + YESTERDAY: { + displayName: 'Yesterday', + }, + LAST_3_DAYS: { + displayName: 'Last 3 days', + }, + LAST_7_DAYS: { + displayName: 'Last 7 days', + }, + LAST_14_DAYS: { + displayName: 'Last 14 days', + }, + LAST_30_DAYS: { + displayName: 'Last 30 days', + }, + LAST_60_DAYS: { + displayName: 'Last 60 days', + }, + LAST_90_DAYS: { + displayName: 'Last 90 days', + }, + LAST_180_DAYS: { + displayName: 'Last 180 days', + }, + }, + }, + { + name: 'Weekly', + displayName: 'Weekly', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'WeeklyWednesday', + displayName: 'Weekly Wednesday', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'WeeklyThursday', + displayName: 'Weekly Thursday', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'WeeklyFriday', + displayName: 'WeeklyFriday', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'WeeklySaturday', + displayName: 'Weekly Saturday', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'WeeklySunday', + displayName: 'Weekly Sunday', + relativePeriods: { + WEEKS_THIS_YEAR: { + displayName: 'Weeks this year', + }, + THIS_WEEK: { + displayName: 'This week', + }, + LAST_WEEK: { + displayName: 'Last week', + }, + LAST_4_WEEKS: { + displayName: 'Last 4 weeks', + }, + LAST_12_WEEKS: { + displayName: 'Last 12 weeks', + }, + LAST_52_WEEKS: { + displayName: 'Last 52 weeks', + }, + }, + }, + { + name: 'BiWeekly', + displayName: 'Bi-weekly', + relativePeriods: { + THIS_BIWEEK: { + displayName: 'This bi-week', + }, + LAST_BIWEEK: { + displayName: 'Last bi-week', + }, + LAST_4_BIWEEKS: { + displayName: 'Last 4 bi-weeks', + }, + }, + }, + { + name: 'Monthly', + displayName: 'Monthly', + relativePeriods: { + THIS_MONTH: { + displayName: 'This month', + }, + LAST_MONTH: { + displayName: 'Last month', + }, + MONTHS_THIS_YEAR: { + displayName: 'Months this year', + }, + MONTHS_LAST_YEAR: { + displayName: 'Months last year', + }, + LAST_12_MONTHS: { + displayName: 'Last 12 months', + }, + LAST_6_MONTHS: { + displayName: 'Last 6 months', + }, + LAST_3_MONTHS: { + displayName: 'Last 3 months', + }, + }, + }, + { + name: 'BiMonthly', + displayName: 'Bi-monthly', + relativePeriods: { + THIS_BIMONTH: { + displayName: 'This bi-month', + }, + LAST_BIMONTH: { + displayName: 'Last bi-month', + }, + BIMONTHS_THIS_YEAR: { + displayName: 'Bi-months this year', + }, + LAST_6_BIMONTHS: { + displayName: 'Last 6 bi-months', + }, + }, + }, + { + name: 'Quarterly', + displayName: 'Quarterly', + relativePeriods: { + THIS_QUARTER: { + displayName: 'This quarter', + }, + LAST_QUARTER: { + displayName: 'Last quarter', + }, + QUARTERS_THIS_YEAR: { + displayName: 'Quarters this year', + }, + QUARTERS_LAST_YEAR: { + displayName: 'Quarters last year', + }, + LAST_4_QUARTERS: { + displayName: 'Last 4 quarters', + }, + }, + }, + { + name: 'QuarterlyNov', + displayName: 'Quarterly November', + relativePeriods: { + THIS_QUARTER: { + displayName: 'This quarter', + }, + LAST_QUARTER: { + displayName: 'Last quarter', + }, + QUARTERS_THIS_YEAR: { + displayName: 'Quarters this year', + }, + QUARTERS_LAST_YEAR: { + displayName: 'Quarters last year', + }, + LAST_4_QUARTERS: { + displayName: 'Last 4 quarters', + }, + }, + }, + { + name: 'SixMonthly', + displayName: 'Six-monthly', + relativePeriods: { + THIS_SIX_MONTH: { + displayName: 'This six-month', + }, + LAST_SIX_MONTH: { + displayName: 'Last six-month', + }, + LAST_2_SIXMONTHS: { + displayName: 'Last 2 six-months', + }, + }, + }, + { + name: 'SixMonthlyApril', + displayName: 'Six-monthly April', + relativePeriods: { + THIS_SIX_MONTH: { + displayName: 'This six-month', + }, + LAST_SIX_MONTH: { + displayName: 'Last six-month', + }, + LAST_2_SIXMONTHS: { + displayName: 'Last 2 six-months', + }, + }, + }, + { + name: 'SixMonthlyNov', + displayName: 'Six-monthly November', + relativePeriods: { + THIS_SIX_MONTH: { + displayName: 'This six-month', + }, + LAST_SIX_MONTH: { + displayName: 'Last six-month', + }, + LAST_2_SIXMONTHS: { + displayName: 'Last 2 six-months', + }, + }, + }, + { + name: 'Yearly', + displayName: 'Yearly', + relativePeriods: { + THIS_YEAR: { + displayName: 'This year', + }, + LAST_YEAR: { + displayName: 'Last year', + }, + LAST_5_YEARS: { + displayName: 'Last 5 years', + }, + LAST_10_YEARS: { + displayName: 'Last 10 years', + }, + }, + }, + { + name: 'FinancialFeb', + displayName: 'Financial February', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialApril', + displayName: 'Financial April', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialJuly', + displayName: 'Financial July', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialAug', + displayName: 'Financial August', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialSep', + displayName: 'FinancialSep', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialOct', + displayName: 'Financial October', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, + { + name: 'FinancialNov', + displayName: 'Financial November', + relativePeriods: { + THIS_FINANCIAL_YEAR: { + displayName: 'This financial year', + }, + LAST_FINANCIAL_YEAR: { + displayName: 'Last financial year', + }, + LAST_5_FINANCIAL_YEARS: { + displayName: 'Last 5 financial years', + }, + LAST_10_FINANCIAL_YEARS: { + displayName: 'LAST_10_FINANCIAL_YEARS', + }, + }, + }, +] diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index 08314a6f6..e74807b84 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -273,11 +273,16 @@ describe('useV44DataOutputPeriodTypes', () => { mockQueryState.v44Refetch = jest.fn() mockQueryState.localeRefetch = jest.fn() mockQueryState.v44Data = undefined + mockQueryState.localeData = undefined + mockQueryState.localeError = undefined - useDataQuery.mockImplementation((query, options) => { + useDataQuery.mockImplementation((query) => { if (query.userSettings) { - mockQueryState.localeOptions = options - return { refetch: mockQueryState.localeRefetch } + return { + data: mockQueryState.localeData, + error: mockQueryState.localeError, + refetch: mockQueryState.localeRefetch, + } } return { data: mockQueryState.v44Data, @@ -294,21 +299,38 @@ describe('useV44DataOutputPeriodTypes', () => { expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() }) + it('does not fetch period types when disabled and the user locale is loaded elsewhere', () => { + // the userSettings query is shared with identical queries, e.g. in PeriodDimension + mockQueryState.localeData = { userSettings: { keyUiLocale: 'fr' } } + + renderHook(() => useV44DataOutputPeriodTypes({ enabled: false })) + + expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() + }) + it('fetches period types in the user ui locale', () => { - renderHook(() => useV44DataOutputPeriodTypes({ enabled: true })) + const { rerender } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) expect(mockQueryState.localeRefetch).toHaveBeenCalledTimes(1) + expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() - mockQueryState.localeOptions.onComplete({ - userSettings: { keyUiLocale: 'fr' }, - }) + mockQueryState.localeData = { userSettings: { keyUiLocale: 'fr' } } + rerender() + + expect(mockQueryState.v44Refetch).toHaveBeenCalledTimes(1) expect(mockQueryState.v44Refetch).toHaveBeenCalledWith({ locale: 'fr', }) + }) + + it('still fetches period types if the user locale request fails', () => { + mockQueryState.localeError = new Error('failed') + + renderHook(() => useV44DataOutputPeriodTypes({ enabled: true })) - // still fetches period types if the user locale request fails - mockQueryState.localeOptions.onError() - expect(mockQueryState.v44Refetch).toHaveBeenLastCalledWith({}) + expect(mockQueryState.v44Refetch).toHaveBeenCalledWith({}) }) it('returns enabled types and relative period name overrides', () => { diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index 7509bd550..ebcaf64a0 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -73,12 +73,14 @@ const getRelativePeriodsFromPeriodType = (periodType) => export const useV44DataOutputPeriodTypes = ({ enabled }) => { const { data, error, refetch } = useDataQuery(v44Query, { lazy: true }) - const { refetch: fetchUserLocale } = useDataQuery(userLocaleQuery, { - lazy: true, - onComplete: ({ userSettings }) => - refetch({ locale: userSettings?.keyUiLocale }), - onError: () => refetch({}), - }) + // Not using onComplete: the query is shared with other identical + // userSettings queries (e.g. in PeriodDimension), whose completion would + // trigger the period types request also when disabled + const { + data: localeData, + error: localeError, + refetch: fetchUserLocale, + } = useDataQuery(userLocaleQuery, { lazy: true }) useEffect(() => { if (enabled) { @@ -86,6 +88,17 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { } }, [enabled, fetchUserLocale]) + const locale = localeData?.userSettings?.keyUiLocale + + useEffect(() => { + if (!enabled || (!localeData && !localeError)) { + return + } + + // still fetch period types if the user locale request fails + refetch(locale ? { locale } : {}) + }, [enabled, locale, localeData, localeError, refetch]) + return useMemo(() => { if (!enabled || error || !data?.periodTypes) { return null From 8c97aa1552725cfdfb0701706c8cbc8e8938543f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Tue, 20 Oct 2026 15:10:24 +0200 Subject: [PATCH 05/11] chore: sonarqube smell --- src/__demo__/PeriodDimension.stories.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__demo__/PeriodDimension.stories.js b/src/__demo__/PeriodDimension.stories.js index 699778141..bff1feaa0 100644 --- a/src/__demo__/PeriodDimension.stories.js +++ b/src/__demo__/PeriodDimension.stories.js @@ -223,7 +223,8 @@ V44CustomLabels.decorators = [ export const V44Translated = () => { return ( console.log(selected)} /> ) From 7a13b8abb3b71cb0da47bb64f214005874729dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Wed, 21 Oct 2026 14:43:29 +0200 Subject: [PATCH 06/11] fix: let server use db locale for period type labels --- src/__demo__/PeriodDimension.stories.js | 7 +-- .../PeriodDimension/__tests__/v44.spec.js | 60 ++++--------------- .../PeriodDimension/versions/v44.js | 40 ++----------- 3 files changed, 21 insertions(+), 86 deletions(-) diff --git a/src/__demo__/PeriodDimension.stories.js b/src/__demo__/PeriodDimension.stories.js index bff1feaa0..2ac01b27d 100644 --- a/src/__demo__/PeriodDimension.stories.js +++ b/src/__demo__/PeriodDimension.stories.js @@ -57,12 +57,12 @@ const v44Data = ({ enabledPeriodTypes = periodTypesV44.map(({ name }) => name), periodTypeLabels = {}, relativePeriodLabels = {}, - uiLocale = 'en', weeklyStart = 'WEEKLY', financialYearStart = 'FINANCIAL_YEAR_OCTOBER', analysisRelativePeriod = 'LAST_12_MONTHS', } = {}) => ({ - userSettings: { keyUiLocale: uiLocale }, + // PeriodDimension reads keyUiLocale for generating fixed periods + userSettings: { keyUiLocale: 'en' }, 'configuration/dataOutputPeriodTypes': applyV44Labels( periodTypesV44.filter(({ name }) => enabledPeriodTypes.includes(name)), { periodTypeLabels, relativePeriodLabels } @@ -230,12 +230,11 @@ export const V44Translated = () => { ) } -V44Translated.storyName = 'v44: label translations (ui locale fr)' +V44Translated.storyName = 'v44: label translations (db locale fr)' V44Translated.decorators = [ withServer({ minor: 44, data: v44Data({ - uiLocale: 'fr', enabledPeriodTypes: ['Daily', 'Weekly', 'Monthly', 'Yearly'], periodTypeLabels: { Daily: 'Quotidien', diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index e74807b84..ac3b49578 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -271,66 +271,30 @@ describe('useV44DataOutputPeriodTypes', () => { beforeEach(() => { const { useDataQuery } = require('@dhis2/app-runtime') mockQueryState.v44Refetch = jest.fn() - mockQueryState.localeRefetch = jest.fn() mockQueryState.v44Data = undefined - mockQueryState.localeData = undefined - mockQueryState.localeError = undefined - - useDataQuery.mockImplementation((query) => { - if (query.userSettings) { - return { - data: mockQueryState.localeData, - error: mockQueryState.localeError, - refetch: mockQueryState.localeRefetch, - } - } - return { - data: mockQueryState.v44Data, - error: undefined, - refetch: mockQueryState.v44Refetch, - } - }) - }) - - it('does not fetch when disabled', () => { - renderHook(() => useV44DataOutputPeriodTypes({ enabled: false })) - expect(mockQueryState.localeRefetch).not.toHaveBeenCalled() - expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() + useDataQuery.mockImplementation(() => ({ + data: mockQueryState.v44Data, + error: undefined, + refetch: mockQueryState.v44Refetch, + })) }) - it('does not fetch period types when disabled and the user locale is loaded elsewhere', () => { - // the userSettings query is shared with identical queries, e.g. in PeriodDimension - mockQueryState.localeData = { userSettings: { keyUiLocale: 'fr' } } - + it('does not fetch when disabled', () => { renderHook(() => useV44DataOutputPeriodTypes({ enabled: false })) expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() }) - it('fetches period types in the user ui locale', () => { - const { rerender } = renderHook(() => - useV44DataOutputPeriodTypes({ enabled: true }) - ) - - expect(mockQueryState.localeRefetch).toHaveBeenCalledTimes(1) - expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() - - mockQueryState.localeData = { userSettings: { keyUiLocale: 'fr' } } - rerender() - - expect(mockQueryState.v44Refetch).toHaveBeenCalledTimes(1) - expect(mockQueryState.v44Refetch).toHaveBeenCalledWith({ - locale: 'fr', - }) - }) - - it('still fetches period types if the user locale request fails', () => { - mockQueryState.localeError = new Error('failed') + it('fetches period types without a locale (server uses the user db locale)', () => { + const { useDataQuery } = require('@dhis2/app-runtime') renderHook(() => useV44DataOutputPeriodTypes({ enabled: true })) - expect(mockQueryState.v44Refetch).toHaveBeenCalledWith({}) + expect(mockQueryState.v44Refetch).toHaveBeenCalledTimes(1) + expect(mockQueryState.v44Refetch).toHaveBeenCalledWith() + const [query] = useDataQuery.mock.calls[0] + expect(query.periodTypes.params).not.toHaveProperty('locale') }) it('returns enabled types and relative period name overrides', () => { diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index ebcaf64a0..a686766f1 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -1,6 +1,7 @@ // v44: /configuration/dataOutputPeriodTypes returns the enabled period types // with their relative periods nested, all with a `displayName` that already -// resolves label translation -> label -> default name for the requested locale. +// resolves label translation -> label -> default name. No locale is passed, so +// the server uses the user's db locale (keyDbLocale), like for other metadata. // The client no longer derives any names, it only maps server period types to // the client period type ids used for generating fixed periods. // Weekly and financial year relative periods have one set of labels; the @@ -17,22 +18,12 @@ import { import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' import { FINANCIAL, WEEKLY, filterPeriodTypesById } from '../utils/index.js' -const userLocaleQuery = { - userSettings: { - resource: 'userSettings', - params: { - key: ['keyUiLocale'], - }, - }, -} - const v44Query = { periodTypes: { resource: 'configuration/dataOutputPeriodTypes', - params: ({ locale }) => ({ + params: { fields: 'name,displayName,relativePeriods', - ...(locale && { locale }), - }), + }, }, analysisRelativePeriod: { resource: 'systemSettings/keyAnalysisRelativePeriod', @@ -73,31 +64,12 @@ const getRelativePeriodsFromPeriodType = (periodType) => export const useV44DataOutputPeriodTypes = ({ enabled }) => { const { data, error, refetch } = useDataQuery(v44Query, { lazy: true }) - // Not using onComplete: the query is shared with other identical - // userSettings queries (e.g. in PeriodDimension), whose completion would - // trigger the period types request also when disabled - const { - data: localeData, - error: localeError, - refetch: fetchUserLocale, - } = useDataQuery(userLocaleQuery, { lazy: true }) useEffect(() => { if (enabled) { - fetchUserLocale() - } - }, [enabled, fetchUserLocale]) - - const locale = localeData?.userSettings?.keyUiLocale - - useEffect(() => { - if (!enabled || (!localeData && !localeError)) { - return + refetch() } - - // still fetch period types if the user locale request fails - refetch(locale ? { locale } : {}) - }, [enabled, locale, localeData, localeError, refetch]) + }, [enabled, refetch]) return useMemo(() => { if (!enabled || error || !data?.periodTypes) { From a4f94a13c5eb20c992ce63fe30acde8bbc47061d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Wed, 21 Oct 2026 15:04:05 +0200 Subject: [PATCH 07/11] refactor: explicit start setting categories and complete enabledPeriodTypesData prop types --- src/components/PeriodDimension/PeriodTransfer.js | 10 +++++++++- src/components/PeriodDimension/versions/v44.js | 14 +++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/PeriodDimension/PeriodTransfer.js b/src/components/PeriodDimension/PeriodTransfer.js index d89df976d..376f7edbb 100644 --- a/src/components/PeriodDimension/PeriodTransfer.js +++ b/src/components/PeriodDimension/PeriodTransfer.js @@ -328,10 +328,18 @@ PeriodTransfer.propTypes = { enabledPeriodTypesData: PropTypes.shape({ analysisRelativePeriod: PropTypes.string, enabledTypes: PropTypes.array, + // v43 only financialYearDisplayLabel: PropTypes.string, financialYearStart: PropTypes.string, + metaData: PropTypes.objectOf( + PropTypes.shape({ name: PropTypes.string }) + ), noEnabledTypes: PropTypes.bool, - version: PropTypes.number, + // v44 only + relativePeriodStartTypes: PropTypes.objectOf(PropTypes.string), + version: PropTypes.oneOf([43, 44]), + // v43 only + weeklyDisplayLabel: PropTypes.string, }), excludedPeriodTypes: PropTypes.arrayOf(PropTypes.string), height: PropTypes.string, diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index a686766f1..4696e1dc8 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -52,6 +52,10 @@ const RP_CATEGORY_TO_SERVER_PTS = { ], } +// Relative period categories whose relative periods refer to the start variant +// selected in the system settings (see relativePeriodStartTypes) +const START_SETTING_CATEGORIES = [WEEKLY, FINANCIAL] + const getRelativeCategoryId = (serverPtName) => Object.keys(RP_CATEGORY_TO_SERVER_PTS).find((categoryId) => RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(serverPtName) @@ -144,11 +148,11 @@ const getV44RelativeOption = ( // type of the family. Weekly and financial year relative periods refer to // the start variant selected in the system settings, so they are only // available when that variant is enabled. - const startTypeName = relativePeriodStartTypes?.[categoryId] - const sourceType = - startTypeName !== undefined - ? familyTypes.find((pt) => pt.name === startTypeName) - : familyTypes[0] + const sourceType = START_SETTING_CATEGORIES.includes(categoryId) + ? familyTypes.find( + (pt) => pt.name === relativePeriodStartTypes?.[categoryId] + ) + : familyTypes[0] const periods = sourceType ? getRelativePeriodsFromPeriodType(sourceType) From 111f057a72263df069ee231f6608450bd7f41822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Wed, 21 Oct 2026 15:35:42 +0200 Subject: [PATCH 08/11] fix: apply excludedPeriodTypes on v43 --- .../PeriodDimension/__tests__/v43.spec.js | 47 +++++++++++++++++++ .../useDataOutputPeriodTypes.js | 1 + .../PeriodDimension/versions/v43.js | 28 +++++++---- 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 src/components/PeriodDimension/__tests__/v43.spec.js diff --git a/src/components/PeriodDimension/__tests__/v43.spec.js b/src/components/PeriodDimension/__tests__/v43.spec.js new file mode 100644 index 000000000..9769371f2 --- /dev/null +++ b/src/components/PeriodDimension/__tests__/v43.spec.js @@ -0,0 +1,47 @@ +import { getV43PeriodTypeOptions } from '../versions/v43.js' + +// Shape of /api/configuration/dataOutputPeriodTypes (v43) +const enabledTypes = [ + { name: 'Daily', displayLabel: 'Day' }, + { name: 'Weekly', displayLabel: 'Week' }, + { name: 'Monthly', displayLabel: 'Month' }, +] + +const getOptions = (excludedPeriodTypes) => + getV43PeriodTypeOptions({ + enabledPeriodTypesData: { + version: 43, + enabledTypes, + financialYearStart: null, + metaData: {}, + }, + periodsSettings: { calendar: 'gregory', locale: 'en' }, + excludedPeriodTypes, + }) + +describe('v43 period type options', () => { + it('shows all enabled period types when nothing is excluded', () => { + const { fixedOptions, relativeOptions } = getOptions([]) + + expect(fixedOptions.map(({ id }) => id)).toEqual([ + 'DAILY', + 'WEEKLY', + 'MONTHLY', + ]) + expect(relativeOptions.map(({ id }) => id)).toEqual([ + 'DAILY', + 'WEEKLY', + 'MONTHLY', + ]) + }) + + it('applies excludedPeriodTypes to both fixed and relative options', () => { + const { fixedOptions, relativeOptions } = getOptions([ + 'DAILY', + 'WEEKLY', + ]) + + expect(fixedOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + expect(relativeOptions.map(({ id }) => id)).toEqual(['MONTHLY']) + }) +}) diff --git a/src/components/PeriodDimension/useDataOutputPeriodTypes.js b/src/components/PeriodDimension/useDataOutputPeriodTypes.js index d76d05b93..1ba99c5f8 100644 --- a/src/components/PeriodDimension/useDataOutputPeriodTypes.js +++ b/src/components/PeriodDimension/useDataOutputPeriodTypes.js @@ -45,6 +45,7 @@ const getPeriodTypeOptions = ({ return getV43PeriodTypeOptions({ enabledPeriodTypesData, periodsSettings, + excludedPeriodTypes, }) default: return getV42PeriodTypeOptions({ diff --git a/src/components/PeriodDimension/versions/v43.js b/src/components/PeriodDimension/versions/v43.js index 0747baec6..2c5e2222e 100644 --- a/src/components/PeriodDimension/versions/v43.js +++ b/src/components/PeriodDimension/versions/v43.js @@ -14,6 +14,7 @@ import { filterEnabledFixedPeriodTypes, } from '../utils/enabledPeriodTypes.js' import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' +import { filterPeriodTypesById } from '../utils/index.js' import { getRelativePeriodsOptions } from '../utils/relativePeriods.js' const v43Query = { @@ -250,6 +251,7 @@ export const filterEnabledRelativePeriodTypes = ( export const getV43PeriodTypeOptions = ({ enabledPeriodTypesData, periodsSettings, + excludedPeriodTypes, }) => { const { enabledTypes, @@ -260,20 +262,26 @@ export const getV43PeriodTypeOptions = ({ } = enabledPeriodTypesData return { - fixedOptions: applyFixedPeriodTypeDisplayLabels( - filterEnabledFixedPeriodTypes( - getFixedPeriodsOptions(periodsSettings), + fixedOptions: filterPeriodTypesById( + applyFixedPeriodTypeDisplayLabels( + filterEnabledFixedPeriodTypes( + getFixedPeriodsOptions(periodsSettings), + enabledTypes + ), enabledTypes ), - enabledTypes + excludedPeriodTypes ), - relativeOptions: applyDisplayLabelOverrides( - filterEnabledRelativePeriodTypes( - getRelativePeriodsOptions(), - enabledTypes, - financialYearStart + relativeOptions: filterPeriodTypesById( + applyDisplayLabelOverrides( + filterEnabledRelativePeriodTypes( + getRelativePeriodsOptions(), + enabledTypes, + financialYearStart + ), + { financialYearDisplayLabel, weeklyDisplayLabel, metaData } ), - { financialYearDisplayLabel, weeklyDisplayLabel, metaData } + excludedPeriodTypes ), } } From 524c830967ee693d8416db033bbfe8f55aa96062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Thu, 22 Oct 2026 10:06:59 +0200 Subject: [PATCH 09/11] chore: use Set for start setting categories --- src/components/PeriodDimension/versions/v44.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index 4696e1dc8..d1ede39dc 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -54,7 +54,7 @@ const RP_CATEGORY_TO_SERVER_PTS = { // Relative period categories whose relative periods refer to the start variant // selected in the system settings (see relativePeriodStartTypes) -const START_SETTING_CATEGORIES = [WEEKLY, FINANCIAL] +const START_SETTING_CATEGORIES = new Set([WEEKLY, FINANCIAL]) const getRelativeCategoryId = (serverPtName) => Object.keys(RP_CATEGORY_TO_SERVER_PTS).find((categoryId) => @@ -148,7 +148,7 @@ const getV44RelativeOption = ( // type of the family. Weekly and financial year relative periods refer to // the start variant selected in the system settings, so they are only // available when that variant is enabled. - const sourceType = START_SETTING_CATEGORIES.includes(categoryId) + const sourceType = START_SETTING_CATEGORIES.has(categoryId) ? familyTypes.find( (pt) => pt.name === relativePeriodStartTypes?.[categoryId] ) From 80792327c932dc3e0456e64dae61d19cf88933db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Thu, 22 Oct 2026 11:28:09 +0200 Subject: [PATCH 10/11] fix: relative periods for quarterly and six-monthly always use the standard type --- .../PeriodDimension/PeriodTransfer.js | 2 +- .../PeriodDimension/__tests__/v43.spec.js | 96 ++++++++++++++++++- .../PeriodDimension/__tests__/v44.spec.js | 22 ++++- .../PeriodDimension/periodTypeOptions.js | 31 ++++++ .../useDataOutputPeriodTypes.js | 39 +------- .../PeriodDimension/versions/v42.js | 2 +- .../PeriodDimension/versions/v43.js | 4 +- .../PeriodDimension/versions/v44.js | 41 +++----- 8 files changed, 165 insertions(+), 72 deletions(-) create mode 100644 src/components/PeriodDimension/periodTypeOptions.js diff --git a/src/components/PeriodDimension/PeriodTransfer.js b/src/components/PeriodDimension/PeriodTransfer.js index 376f7edbb..5440d83c0 100644 --- a/src/components/PeriodDimension/PeriodTransfer.js +++ b/src/components/PeriodDimension/PeriodTransfer.js @@ -12,8 +12,8 @@ import { import styles from '../styles/DimensionSelector.style.js' import { TransferOption } from '../TransferOption.js' import FixedPeriodFilter from './FixedPeriodFilter.js' +import { getPeriodTypeOptions } from './periodTypeOptions.js' import RelativePeriodFilter from './RelativePeriodFilter.js' -import { getPeriodTypeOptions } from './useDataOutputPeriodTypes.js' import { MONTHLY, QUARTERLY } from './utils/index.js' const RightHeader = ({ infoBoxMessage }) => ( diff --git a/src/components/PeriodDimension/__tests__/v43.spec.js b/src/components/PeriodDimension/__tests__/v43.spec.js index 9769371f2..fe5e6568f 100644 --- a/src/components/PeriodDimension/__tests__/v43.spec.js +++ b/src/components/PeriodDimension/__tests__/v43.spec.js @@ -1,4 +1,10 @@ -import { getV43PeriodTypeOptions } from '../versions/v43.js' +import { getRelativePeriodsOptions } from '../utils/relativePeriods.js' +import { + applyDisplayLabelOverrides, + applyFixedPeriodTypeDisplayLabels, + filterEnabledRelativePeriodTypes, + getV43PeriodTypeOptions, +} from '../versions/v43.js' // Shape of /api/configuration/dataOutputPeriodTypes (v43) const enabledTypes = [ @@ -45,3 +51,91 @@ describe('v43 period type options', () => { expect(relativeOptions.map(({ id }) => id)).toEqual(['MONTHLY']) }) }) + +describe('applyFixedPeriodTypeDisplayLabels', () => { + const fixedOptions = [ + { id: 'WEEKLY', name: 'Weekly' }, + { id: 'MONTHLY', name: 'Monthly' }, + ] + + it('uses the displayLabel of the server period type as name', () => { + expect( + applyFixedPeriodTypeDisplayLabels(fixedOptions, [ + { name: 'Weekly', displayLabel: 'Week' }, + { name: 'Monthly' }, + ]) + ).toEqual([ + { id: 'WEEKLY', name: 'Week' }, + { id: 'MONTHLY', name: 'Monthly' }, + ]) + }) + + it('returns the options unchanged when there are no labels', () => { + expect(applyFixedPeriodTypeDisplayLabels(fixedOptions, null)).toBe( + fixedOptions + ) + expect( + applyFixedPeriodTypeDisplayLabels(fixedOptions, [ + { name: 'Weekly' }, + ]) + ).toBe(fixedOptions) + }) +}) + +describe('filterEnabledRelativePeriodTypes', () => { + const filter = (enabledServerPeriodTypes, financialYearStart) => + filterEnabledRelativePeriodTypes( + getRelativePeriodsOptions(), + enabledServerPeriodTypes, + financialYearStart + ).map(({ id }) => id) + + it('returns no categories when no period types are enabled', () => { + expect(filter([])).toEqual([]) + expect(filter(null)).toEqual([]) + }) + + it('shows a category when any period type of its family is enabled', () => { + expect( + filter([{ name: 'WeeklySunday' }, { name: 'SixMonthlyApril' }]) + ).toEqual(['WEEKLY', 'SIXMONTHLY']) + }) + + it('shows financial years only when the financial year start is set', () => { + const enabledTypes = [{ name: 'Monthly' }, { name: 'FinancialOct' }] + + expect(filter(enabledTypes)).toEqual(['MONTHLY']) + expect(filter(enabledTypes, 'FINANCIAL_YEAR_OCTOBER')).toEqual([ + 'MONTHLY', + 'FINANCIAL', + ]) + }) +}) + +describe('applyDisplayLabelOverrides', () => { + const relativeOptions = getRelativePeriodsOptions().filter(({ id }) => + ['WEEKLY', 'MONTHLY'].includes(id) + ) + + it('renames the weekly category and its relative periods', () => { + const [weeklyOption, monthlyOption] = applyDisplayLabelOverrides( + relativeOptions, + { + weeklyDisplayLabel: 'EPI week', + metaData: { THIS_WEEK: { name: 'This EPI week' } }, + } + ) + + expect(weeklyOption.name).toBe('EPI week') + expect( + weeklyOption.getPeriods().find(({ id }) => id === 'THIS_WEEK').name + ).toBe('This EPI week') + expect(monthlyOption).toBe(relativeOptions[1]) + }) + + it('returns the options unchanged when there are no labels', () => { + expect(applyDisplayLabelOverrides(relativeOptions, {})).toBe( + relativeOptions + ) + }) +}) diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index ac3b49578..aeb3fed88 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -55,6 +55,13 @@ const quarterlyNov = { displayName: 'Quarterly Nov', relativePeriods: quarterlyRelativePeriods, } +const sixMonthlyApril = { + name: 'SixMonthlyApril', + displayName: 'Six-monthly April', + relativePeriods: { + THIS_SIX_MONTH: { displayName: 'Ce semestre' }, + }, +} const financialRelativePeriods = { THIS_FINANCIAL_YEAR: { displayName: 'Cet exercice' }, LAST_FINANCIAL_YEAR: { displayName: 'Exercice précédent' }, @@ -214,7 +221,7 @@ describe('v44 period type options', () => { ]) }) - it('shows one quarterly category named after the first enabled quarterly type', () => { + it('shows quarterly relative periods for the standard quarterly type', () => { expect( summarize(getOptions([quarterly, quarterlyNov]).relativeOptions) ).toEqual([ @@ -224,9 +231,16 @@ describe('v44 period type options', () => { periods: ['THIS_QUARTER:Ce trimestre'], }, ]) - expect( - getOptions([quarterlyNov]).relativeOptions.map(({ name }) => name) - ).toEqual(['Quarterly Nov']) + }) + + it('hides quarterly and six-monthly relative periods when only a start variant is enabled', () => { + const { fixedOptions, relativeOptions } = getOptions([ + quarterlyNov, + sixMonthlyApril, + ]) + + expect(fixedOptions.map(({ id }) => id)).toEqual(['SIXMONTHLYAPR']) + expect(relativeOptions).toEqual([]) }) it('hides a category when the server returns no relative periods for it', () => { diff --git a/src/components/PeriodDimension/periodTypeOptions.js b/src/components/PeriodDimension/periodTypeOptions.js new file mode 100644 index 000000000..6603a0fe0 --- /dev/null +++ b/src/components/PeriodDimension/periodTypeOptions.js @@ -0,0 +1,31 @@ +import { getV42PeriodTypeOptions } from './versions/v42.js' +import { getV43PeriodTypeOptions } from './versions/v43.js' +import { getV44PeriodTypeOptions } from './versions/v44.js' + +const getPeriodTypeOptions = ({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, +}) => { + switch (enabledPeriodTypesData?.version) { + case 44: + return getV44PeriodTypeOptions({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, + }) + case 43: + return getV43PeriodTypeOptions({ + enabledPeriodTypesData, + periodsSettings, + excludedPeriodTypes, + }) + default: + return getV42PeriodTypeOptions({ + periodsSettings, + excludedPeriodTypes, + }) + } +} + +export { getPeriodTypeOptions } diff --git a/src/components/PeriodDimension/useDataOutputPeriodTypes.js b/src/components/PeriodDimension/useDataOutputPeriodTypes.js index 1ba99c5f8..f7d2d56fd 100644 --- a/src/components/PeriodDimension/useDataOutputPeriodTypes.js +++ b/src/components/PeriodDimension/useDataOutputPeriodTypes.js @@ -1,13 +1,6 @@ import { useConfig } from '@dhis2/app-runtime' -import { getV42PeriodTypeOptions } from './versions/v42.js' -import { - getV43PeriodTypeOptions, - useV43DataOutputPeriodTypes, -} from './versions/v43.js' -import { - getV44PeriodTypeOptions, - useV44DataOutputPeriodTypes, -} from './versions/v44.js' +import { useV43DataOutputPeriodTypes } from './versions/v43.js' +import { useV44DataOutputPeriodTypes } from './versions/v44.js' const useDataOutputPeriodTypes = () => { const { serverVersion } = useConfig() @@ -29,30 +22,4 @@ const useDataOutputPeriodTypes = () => { } } -const getPeriodTypeOptions = ({ - enabledPeriodTypesData, - periodsSettings, - excludedPeriodTypes, -}) => { - switch (enabledPeriodTypesData?.version) { - case 44: - return getV44PeriodTypeOptions({ - enabledPeriodTypesData, - periodsSettings, - excludedPeriodTypes, - }) - case 43: - return getV43PeriodTypeOptions({ - enabledPeriodTypesData, - periodsSettings, - excludedPeriodTypes, - }) - default: - return getV42PeriodTypeOptions({ - periodsSettings, - excludedPeriodTypes, - }) - } -} - -export { useDataOutputPeriodTypes, getPeriodTypeOptions } +export { useDataOutputPeriodTypes } diff --git a/src/components/PeriodDimension/versions/v42.js b/src/components/PeriodDimension/versions/v42.js index c62fcbe6a..a91f4effa 100644 --- a/src/components/PeriodDimension/versions/v42.js +++ b/src/components/PeriodDimension/versions/v42.js @@ -1,6 +1,6 @@ // v42: no enabled period types API, all period types and relative periods // come from the hardcoded client lists. -// Remove this file (and its branch in getPeriodTypeOptions) when v42 is no longer supported. +// Remove this file (and its branch in periodTypeOptions.js) when v42 is no longer supported. import { getFixedPeriodsOptions } from '../utils/fixedPeriods.js' import { FYAUG, diff --git a/src/components/PeriodDimension/versions/v43.js b/src/components/PeriodDimension/versions/v43.js index 2c5e2222e..5c2b59d33 100644 --- a/src/components/PeriodDimension/versions/v43.js +++ b/src/components/PeriodDimension/versions/v43.js @@ -1,8 +1,8 @@ // v43: enabled period types from /configuration/dataOutputPeriodTypes, with a // single `label` per period type. Relative period names are derived client side // from that label ("This {label}", "Last {label}", ...). -// Remove this file (and its branch in useDataOutputPeriodTypes and -// getPeriodTypeOptions) when v43 is no longer supported. +// Remove this file (and its branches in useDataOutputPeriodTypes and +// periodTypeOptions.js) when v43 is no longer supported. import { useDataQuery } from '@dhis2/app-runtime' import { useEffect, useMemo } from 'react' import { diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index d1ede39dc..33095b02f 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -39,17 +39,10 @@ const v44Query = { // Server period types grouped by the relative period category they belong to. // The category ids (e.g. WEEKLY, FINANCIAL) are part of the public API // (excludedPeriodTypes, default selection) and the server has no equivalent. +// The first period type of each family is the standard one. const RP_CATEGORY_TO_SERVER_PTS = { ...RP_CATEGORY_TO_FP_DEPENDENCIES, - [FINANCIAL]: [ - 'FinancialFeb', - 'FinancialApril', - 'FinancialJuly', - 'FinancialAug', - 'FinancialSep', - 'FinancialOct', - 'FinancialNov', - ], + [FINANCIAL]: Object.values(FY_SETTING_TO_SERVER_PT), } // Relative period categories whose relative periods refer to the start variant @@ -140,19 +133,17 @@ const getV44RelativeOption = ( enabledTypes, relativePeriodStartTypes ) => { - const familyTypes = enabledTypes.filter((pt) => - RP_CATEGORY_TO_SERVER_PTS[categoryId].includes(pt.name) - ) - // The server nests the relative periods of a family under every period - // type of the family. Weekly and financial year relative periods refer to - // the start variant selected in the system settings, so they are only - // available when that variant is enabled. - const sourceType = START_SETTING_CATEGORIES.has(categoryId) - ? familyTypes.find( - (pt) => pt.name === relativePeriodStartTypes?.[categoryId] - ) - : familyTypes[0] + // type of the family, but they only refer to one of them: the start variant + // selected in the system settings for weekly and financial years, and the + // standard period type (first in the family) for the others, as there are + // no settings for e.g. quarterly or six-monthly start variants. The + // relative periods are only available when that period type is enabled. + const sourceTypeName = START_SETTING_CATEGORIES.has(categoryId) + ? relativePeriodStartTypes?.[categoryId] + : RP_CATEGORY_TO_SERVER_PTS[categoryId][0] + + const sourceType = enabledTypes.find((pt) => pt.name === sourceTypeName) const periods = sourceType ? getRelativePeriodsFromPeriodType(sourceType) @@ -170,12 +161,8 @@ const getV44RelativeOption = ( } const getV44RelativeOptions = ({ enabledTypes, relativePeriodStartTypes }) => - enabledTypes - .map((pt) => getRelativeCategoryId(pt.name)) - .filter( - (categoryId, index, categoryIds) => - categoryId && categoryIds.indexOf(categoryId) === index - ) + [...new Set(enabledTypes.map((pt) => getRelativeCategoryId(pt.name)))] + .filter(Boolean) .map((categoryId) => getV44RelativeOption( categoryId, From dee518959bf5408530a971d3229897ea918f9bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Henrik=20=C3=98verland?= Date: Thu, 22 Oct 2026 11:50:11 +0200 Subject: [PATCH 11/11] fix: wait for enabled period types before showing the period selector --- .../PeriodDimension/PeriodDimension.js | 22 +++++++- .../__tests__/PeriodDimension.spec.js | 20 ++++++- .../PeriodDimension/__tests__/v44.spec.js | 53 +++++++++++++++---- .../useDataOutputPeriodTypes.js | 15 ++++-- .../PeriodDimension/versions/v43.js | 7 ++- .../PeriodDimension/versions/v44.js | 4 +- 6 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/components/PeriodDimension/PeriodDimension.js b/src/components/PeriodDimension/PeriodDimension.js index e149030bd..d7c4b6949 100644 --- a/src/components/PeriodDimension/PeriodDimension.js +++ b/src/components/PeriodDimension/PeriodDimension.js @@ -1,6 +1,8 @@ import { useConfig, useDataQuery } from '@dhis2/app-runtime' +import { CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' +import { TRANSFER_HEIGHT } from '../../modules/dimensionSelectorHelper.js' import { DIMENSION_ID_PERIOD } from '../../modules/predefinedDimensions.js' import PeriodTransfer from './PeriodTransfer.js' import { useDataOutputPeriodTypes } from './useDataOutputPeriodTypes.js' @@ -29,7 +31,7 @@ const PeriodDimension = ({ const { systemInfo } = config const userSettingsResult = useDataQuery(userSettingsQuery) - const { enabledPeriodTypesData } = useDataOutputPeriodTypes() + const { enabledPeriodTypesData, loading } = useDataOutputPeriodTypes() const { calendar = 'gregory' } = systemInfo const { data: { userSettings: { keyUiLocale: locale } = {} } = {} } = @@ -49,6 +51,24 @@ const PeriodDimension = ({ enabledPeriodTypesData?.metaData ) + // wait for the enabled period types, otherwise the transfer briefly shows + // the full client lists and picks its default period type from them + if (loading) { + return ( +
+ +
+ ) + } + return ( ({ useConfig: () => ({ systemInfo: {}, - serverVersion: { minor: 42 }, + serverVersion: { minor: mockServerMinorVersion }, }), useDataQuery: jest.fn().mockImplementation((_query, options) => { if (options?.lazy) { @@ -27,7 +29,10 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), })) -afterEach(jest.clearAllMocks) +afterEach(() => { + jest.clearAllMocks() + mockServerMinorVersion = 42 +}) const props = { selectedPeriods: [], @@ -62,3 +67,14 @@ test('PeriodDimension can toggle between relative and fixed period tab', async ( screen.getByTestId('period-dimension-fixed-period-filter') ).toBeInTheDocument() }) + +test('PeriodDimension shows a loader while the enabled period types are loading', () => { + mockServerMinorVersion = 44 + + const { container } = render() + + expect( + container.querySelector('[data-test="period-dimension-loader"]') + ).toBeInTheDocument() + expect(screen.queryByText('Relative periods')).not.toBeInTheDocument() +}) diff --git a/src/components/PeriodDimension/__tests__/v44.spec.js b/src/components/PeriodDimension/__tests__/v44.spec.js index aeb3fed88..b233ddbe0 100644 --- a/src/components/PeriodDimension/__tests__/v44.spec.js +++ b/src/components/PeriodDimension/__tests__/v44.spec.js @@ -286,18 +286,49 @@ describe('useV44DataOutputPeriodTypes', () => { const { useDataQuery } = require('@dhis2/app-runtime') mockQueryState.v44Refetch = jest.fn() mockQueryState.v44Data = undefined + mockQueryState.v44Error = undefined useDataQuery.mockImplementation(() => ({ data: mockQueryState.v44Data, - error: undefined, + error: mockQueryState.v44Error, refetch: mockQueryState.v44Refetch, })) }) it('does not fetch when disabled', () => { - renderHook(() => useV44DataOutputPeriodTypes({ enabled: false })) + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: false }) + ) expect(mockQueryState.v44Refetch).not.toHaveBeenCalled() + expect(result.current).toEqual({ + enabledPeriodTypesData: null, + loading: false, + }) + }) + + it('is loading until the period types have been fetched', () => { + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current).toEqual({ + enabledPeriodTypesData: null, + loading: true, + }) + }) + + it('stops loading without data when the request fails', () => { + mockQueryState.v44Error = new Error('Request failed') + + const { result } = renderHook(() => + useV44DataOutputPeriodTypes({ enabled: true }) + ) + + expect(result.current).toEqual({ + enabledPeriodTypesData: null, + loading: false, + }) }) it('fetches period types without a locale (server uses the user db locale)', () => { @@ -327,7 +358,7 @@ describe('useV44DataOutputPeriodTypes', () => { useV44DataOutputPeriodTypes({ enabled: true }) ) - expect(result.current).toEqual({ + expect(result.current.enabledPeriodTypesData).toEqual({ version: 44, enabledTypes: [daily, monthly], analysisRelativePeriod: 'LAST_12_MONTHS', @@ -353,7 +384,9 @@ describe('useV44DataOutputPeriodTypes', () => { useV44DataOutputPeriodTypes({ enabled: true }) ) - expect(result.current.relativePeriodStartTypes).toEqual({ + expect( + result.current.enabledPeriodTypesData.relativePeriodStartTypes + ).toEqual({ WEEKLY: 'Weekly', FINANCIAL: null, }) @@ -368,11 +401,11 @@ describe('useV44DataOutputPeriodTypes', () => { useV44DataOutputPeriodTypes({ enabled: true }) ) - expect(result.current.enabledTypes.map(({ name }) => name)).toEqual([ - 'Daily', - 'Monthly', - 'FinancialOct', - ]) + expect( + result.current.enabledPeriodTypesData.enabledTypes.map( + ({ name }) => name + ) + ).toEqual(['Daily', 'Monthly', 'FinancialOct']) }) it('flags when no period types are enabled', () => { @@ -382,6 +415,6 @@ describe('useV44DataOutputPeriodTypes', () => { useV44DataOutputPeriodTypes({ enabled: true }) ) - expect(result.current.noEnabledTypes).toBe(true) + expect(result.current.enabledPeriodTypesData.noEnabledTypes).toBe(true) }) }) diff --git a/src/components/PeriodDimension/useDataOutputPeriodTypes.js b/src/components/PeriodDimension/useDataOutputPeriodTypes.js index f7d2d56fd..9659c6362 100644 --- a/src/components/PeriodDimension/useDataOutputPeriodTypes.js +++ b/src/components/PeriodDimension/useDataOutputPeriodTypes.js @@ -7,18 +7,23 @@ const useDataOutputPeriodTypes = () => { const supportsEnabledPeriodTypes = serverVersion.minor >= 43 const supportsPeriodLabelTranslations = serverVersion.minor >= 44 - const v43Data = useV43DataOutputPeriodTypes({ + const v43 = useV43DataOutputPeriodTypes({ enabled: supportsEnabledPeriodTypes && !supportsPeriodLabelTranslations, }) - const v44Data = useV44DataOutputPeriodTypes({ + const v44 = useV44DataOutputPeriodTypes({ enabled: supportsPeriodLabelTranslations, }) + // enabledPeriodTypesData stays null on v42 and when the request fails, + // loading tells consumers whether to wait for it + const { enabledPeriodTypesData, loading } = supportsPeriodLabelTranslations + ? v44 + : v43 + return { supportsEnabledPeriodTypes, - enabledPeriodTypesData: supportsPeriodLabelTranslations - ? v44Data - : v43Data, + enabledPeriodTypesData, + loading, } } diff --git a/src/components/PeriodDimension/versions/v43.js b/src/components/PeriodDimension/versions/v43.js index 5c2b59d33..3bcb990c6 100644 --- a/src/components/PeriodDimension/versions/v43.js +++ b/src/components/PeriodDimension/versions/v43.js @@ -45,7 +45,7 @@ export const useV43DataOutputPeriodTypes = ({ enabled }) => { } }, [enabled, v43Refetch]) - return useMemo(() => { + const enabledPeriodTypesData = useMemo(() => { if (!enabled) { return null } @@ -143,6 +143,11 @@ export const useV43DataOutputPeriodTypes = ({ enabled }) => { noEnabledTypes: false, } }, [enabled, v43Data, v43Error]) + + return { + enabledPeriodTypesData, + loading: enabled && !v43Data && !v43Error, + } } /** diff --git a/src/components/PeriodDimension/versions/v44.js b/src/components/PeriodDimension/versions/v44.js index 33095b02f..b0c7775bb 100644 --- a/src/components/PeriodDimension/versions/v44.js +++ b/src/components/PeriodDimension/versions/v44.js @@ -68,7 +68,7 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { } }, [enabled, refetch]) - return useMemo(() => { + const enabledPeriodTypesData = useMemo(() => { if (!enabled || error || !data?.periodTypes) { return null } @@ -106,6 +106,8 @@ export const useV44DataOutputPeriodTypes = ({ enabled }) => { noEnabledTypes: enabledTypes.length === 0, } }, [enabled, data, error]) + + return { enabledPeriodTypesData, loading: enabled && !data && !error } } const getV44FixedOptions = ({ enabledTypes, periodsSettings }) => {