diff --git a/i18n/en.pot b/i18n/en.pot index aa44a2abe..64d431fcf 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-23T14:32:18.188Z\n" -"PO-Revision-Date: 2026-09-23T14:32:18.188Z\n" +"POT-Creation-Date: 2026-10-01T13:14:58.128Z\n" +"PO-Revision-Date: 2026-10-01T13:14:58.129Z\n" msgid "" "Some dimensions were not added because they cannot be used in a " @@ -232,12 +232,6 @@ msgstr "Program stage: {{- stageName}}" msgid "Grouping: {{- groupingName}}" msgstr "Grouping: {{- groupingName}}" -msgid "Levels" -msgstr "Levels" - -msgid "Groups" -msgstr "Groups" - msgid "Using: {{- itemName}} ({{- aggregationType}})" msgstr "Using: {{- itemName}} ({{- aggregationType}})" @@ -594,6 +588,12 @@ msgstr "Skip rounding" msgid "Add a subtitle" msgstr "Add a subtitle" +msgid "Auto generated" +msgstr "Auto generated" + +msgid "Custom" +msgstr "Custom" + msgid "Add a title" msgstr "Add a title" @@ -911,9 +911,6 @@ msgstr "Min value (sum in org unit hierarchy)" msgid "Max value (sum in org unit hierarchy)" msgstr "Max value (sum in org unit hierarchy)" -msgid "Custom" -msgstr "Custom" - msgid "equal to (=)" msgstr "equal to (=)" @@ -1029,6 +1026,12 @@ msgstr "Enrollment status" msgid "Registration org. unit" msgstr "Registration org. unit" +msgid "Levels" +msgstr "Levels" + +msgid "Groups" +msgstr "Groups" + msgid "Enrollment date" msgstr "Enrollment date" @@ -1303,6 +1306,18 @@ msgstr "Completed" msgid "Scheduled" msgstr "Scheduled" +msgid "{{- label}} count" +msgstr "{{- label}} count" + +msgid "{{- label}} list" +msgstr "{{- label}} list" + +msgid "Enrollments" +msgstr "Enrollments" + +msgid "Events" +msgstr "Events" + msgid "" "Track or compare changes over time. Recommend period as category. (adjust " "for EVER)" diff --git a/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx b/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx index e3aca146c..0415dc246 100644 --- a/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx +++ b/src/components/layout-panel/axis/__tests__/grouping-chip.spec.tsx @@ -49,10 +49,13 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( + />, + { metadata: { L1: '0 - 10', L2: '10 - 20' } } ) expect( @@ -66,7 +69,9 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( @@ -84,7 +89,9 @@ describe('TooltipContent grouping', () => { await renderWithAppWrapper( ) diff --git a/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts b/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts index 80a917a3a..6b8c8e719 100644 --- a/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts +++ b/src/components/layout-panel/axis/__tests__/use-tooltip-content-data.spec.ts @@ -1,10 +1,8 @@ -import { useAddMetadata } from '@hooks' import { visUiConfigSlice, type VisUiConfigState, } from '@store/vis-ui-config-slice' import { renderHookWithAppWrapper } from '@test-utils/app-wrapper' -import { act } from '@testing-library/react' import { describe, expect, it } from 'vitest' import type { LayoutDimension } from '../chip' import { useTooltipContentData } from '../use-tooltip-content-data' @@ -34,325 +32,66 @@ const createVisUiConfigState = ( }) describe('useTooltipContentData', () => { - describe('basic functionality', () => { - it('returns empty data for dimension with no items', async () => { - const { result } = await renderHookWithAppWrapper( - () => useTooltipContentData(baseDimension), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState(), - }, + it('returns empty names for a dimension without program context', async () => { + const { result } = await renderHookWithAppWrapper( + () => useTooltipContentData(baseDimension), + { + partialStore: { + reducer: { + visUiConfig: visUiConfigSlice.reducer, }, - } - ) - - expect(result.current).toEqual({ - programName: '', - stageName: '', - itemDisplayNames: [], - }) - }) - - it('returns data element names when metadata is available', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1', 'DE2'], - }, - }), - }, + preloadedState: { + visUiConfig: createVisUiConfigState(), }, - } - ) - - // Initially shows IDs since metadata is not loaded - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'DE1', - 'DE2', - ]) - - // Add metadata - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Data Element 1', - }) - result.current.addMetadata({ - uid: 'DE2', - name: 'Data Element 2', - }) - }) - - // Now shows names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Data Element 1', - 'Data Element 2', - ]) - }) - - it('returns program and stage names when available', async () => { - const programDimension: LayoutDimension = { - id: 'programUid.stageUid.dimensionUid', - dimensionId: 'dimensionUid', - dimensionType: 'DATA_ELEMENT', - programId: 'programUid', - programStageId: 'stageUid', - name: 'Program', + }, } + ) - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(programDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState(), - }, - }, - metadata: { - programUid: { - id: 'programUid', - name: 'Test Program', - programType: 'WITHOUT_REGISTRATION', - programStages: [ - { - id: 'stageUid', - name: 'Test Stage', - repeatable: true, - }, - ], - }, - }, - } - ) - - expect(result.current.tooltipData.programName).toBe('Test Program') - expect(result.current.tooltipData.stageName).toBe('Test Stage') - }) - - it('handles organisation unit levels', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['LEVEL-1', 'LEVEL-2'], - }, - }), - }, - }, - } - ) - - // Initially shows level IDs - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Levels: 1, 2', - ]) - - // Add level metadata - act(() => { - result.current.addMetadata({ - uid: '1', - name: 'National', - }) - result.current.addMetadata({ - uid: '2', - name: 'District', - }) - }) - - // Now shows level names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Levels: National, District', - ]) - }) - - it('handles organisation unit groups', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['OU_GROUP-group1', 'OU_GROUP-group2'], - }, - }), - }, - }, - } - ) - - // Initially shows group IDs - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Groups: group1, group2', - ]) - - // Add group metadata - act(() => { - result.current.addMetadata({ - uid: 'group1', - name: 'Group One', - }) - result.current.addMetadata({ - uid: 'group2', - name: 'Group Two', - }) - }) - - // Now shows group names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Groups: Group One, Group Two', - ]) + expect(result.current).toEqual({ + programName: '', + stageName: '', }) + }) - it('handles date ranges', async () => { - const { result } = await renderHookWithAppWrapper( - () => useTooltipContentData(baseDimension), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['2023-01-01_2023-12-31'], - }, - }), - }, + it('returns program and stage names when available', async () => { + const programDimension: LayoutDimension = { + id: 'programUid.stageUid.dimensionUid', + dimensionId: 'dimensionUid', + dimensionType: 'DATA_ELEMENT', + programId: 'programUid', + programStageId: 'stageUid', + name: 'Program', + } + + const { result } = await renderHookWithAppWrapper( + () => useTooltipContentData(programDimension), + { + partialStore: { + reducer: { + visUiConfig: visUiConfigSlice.reducer, }, - } - ) - - // Date ranges should be formatted - expect(result.current.itemDisplayNames).toEqual([ - 'January 1, 2023 - December 31, 2023', - ]) - }) - - it('combines different item types', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1', 'LEVEL-1', 'OU_GROUP-group1'], - }, - }), - }, + preloadedState: { + visUiConfig: createVisUiConfigState(), }, - } - ) - - // Initially shows IDs and formatted level/group - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'DE1', - 'Levels: 1', - 'Groups: group1', - ]) - - // Add metadata - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Data Element 1', - }) - result.current.addMetadata({ - uid: '1', - name: 'National', - }) - result.current.addMetadata({ - uid: 'group1', - name: 'Group One', - }) - }) - - // Now shows all names - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Data Element 1', - 'Levels: National', - 'Groups: Group One', - ]) - }) - }) - - describe('reactive metadata updates', () => { - it('updates when metadata is added dynamically', async () => { - const { result } = await renderHookWithAppWrapper( - () => ({ - tooltipData: useTooltipContentData(baseDimension), - addMetadata: useAddMetadata(), - }), - { - partialStore: { - reducer: { - visUiConfig: visUiConfigSlice.reducer, - }, - preloadedState: { - visUiConfig: createVisUiConfigState({ - itemsByDimension: { - dx: ['DE1'], - }, - }), - }, + }, + metadata: { + programUid: { + id: 'programUid', + name: 'Test Program', + programType: 'WITHOUT_REGISTRATION', + programStages: [ + { + id: 'stageUid', + name: 'Test Stage', + repeatable: true, + }, + ], }, - } - ) - - expect(result.current.tooltipData.itemDisplayNames).toEqual(['DE1']) - - act(() => { - result.current.addMetadata({ - uid: 'DE1', - name: 'Updated Data Element', - }) - }) + }, + } + ) - expect(result.current.tooltipData.itemDisplayNames).toEqual([ - 'Updated Data Element', - ]) - }) + expect(result.current.programName).toBe('Test Program') + expect(result.current.stageName).toBe('Test Stage') }) }) diff --git a/src/components/layout-panel/axis/chip.tsx b/src/components/layout-panel/axis/chip.tsx index 8418335f1..4ff54ed9b 100644 --- a/src/components/layout-panel/axis/chip.tsx +++ b/src/components/layout-panel/axis/chip.tsx @@ -129,7 +129,9 @@ export const Chip: FC = ({ dimension, axisId }) => { content={ diff --git a/src/components/layout-panel/axis/tooltip-content.tsx b/src/components/layout-panel/axis/tooltip-content.tsx index 42a0ab92b..358cde67e 100644 --- a/src/components/layout-panel/axis/tooltip-content.tsx +++ b/src/components/layout-panel/axis/tooltip-content.tsx @@ -1,5 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import type { DimensionType } from '@types' +import { useDimensionValueTexts } from '@hooks' +import type { Conditions } from '@modules/conditions' +import type { DimensionType, SavedVisualization } from '@types' import { type FC } from 'react' import type { LayoutDimension } from './chip' import styles from './styles/tooltip.module.css' @@ -7,15 +9,6 @@ import { useTooltipContentData } from './use-tooltip-content-data' const MAX_LIST_LENGTH = 5 -const ITEM_BASED_DIMENSION_TYPES: ReadonlySet = new Set([ - 'CATEGORY', - 'CATEGORY_OPTION_GROUP_SET', - 'ORGANISATION_UNIT_GROUP_SET', - 'STATUS', - 'PERIOD', - 'ORGANISATION_UNIT', -]) - const NO_FALLBACK_DIMENSION_TYPES: ReadonlySet = new Set([ 'PERIOD', 'ORGANISATION_UNIT', @@ -23,7 +16,9 @@ const NO_FALLBACK_DIMENSION_TYPES: ReadonlySet = new Set([ type TooltipContentProps = { dimension: LayoutDimension - conditionsTexts: string[] + itemIds: string[] + conditions: Conditions + digitGroupSeparator: SavedVisualization['digitGroupSeparator'] groupingName?: string axisId: string } @@ -60,17 +55,21 @@ const ItemsList: FC = ({ itemDisplayNames, dimensionId }) => { export const TooltipContent: FC = ({ dimension, - conditionsTexts, + itemIds, + conditions, + digitGroupSeparator, groupingName, axisId, }) => { - const { programName, stageName, itemDisplayNames } = - useTooltipContentData(dimension) + const { programName, stageName } = useTooltipContentData(dimension) + const itemsList = useDimensionValueTexts({ + dimension, + itemIds, + conditions, + formatValueOptions: { digitGroupSeparator }, + }) const dimensionType = dimension.dimensionType - const isItemBased = - !!dimensionType && ITEM_BASED_DIMENSION_TYPES.has(dimensionType) - const itemsList = isItemBased ? itemDisplayNames : conditionsTexts const showStage = dimensionType === 'DATA_ELEMENT' const emptyShowsNoneSelected = axisId === 'filters' || diff --git a/src/components/layout-panel/axis/use-tooltip-content-data.ts b/src/components/layout-panel/axis/use-tooltip-content-data.ts index 5effcc077..5ed1c2be9 100644 --- a/src/components/layout-panel/axis/use-tooltip-content-data.ts +++ b/src/components/layout-panel/axis/use-tooltip-content-data.ts @@ -1,71 +1,21 @@ -import { ouIdHelper } from '@dhis2/analytics' -import i18n from '@dhis2/d2-i18n' -import { useMetadataItems, useAppSelector } from '@hooks' -import { extractPlainDimensionId } from '@modules/dimension/ids' +import { useMetadataItems } from '@hooks' import { isProgramMetadataItem } from '@modules/metadata/item-guards' -import { - isStartEndDate, - useLocalizedStartEndDateFormatter, -} from '@modules/utils/dates' -import { getVisUiConfigItemsByDimension } from '@store/vis-ui-config-slice' import { useMemo } from 'react' import type { LayoutDimension } from './chip' -// Helper function to format a list of metadata IDs into a labeled string -const getNameList = ( - idList: Array, - label: string, - metadataItems: ReturnType -) => - idList.reduce((levelString, levelId, index) => { - if (index > 0) { - levelString += ', ' - } - const levelName = metadataItems[levelId]?.name - levelString += levelName ?? levelId - - return levelString - }, `${label}: `) - export const useTooltipContentData = (dimension: LayoutDimension) => { - const itemIds = useAppSelector((state) => - getVisUiConfigItemsByDimension(state, dimension.id) - ) - const formatStartEndDate = useLocalizedStartEndDateFormatter() const { programId, programStageId } = dimension - // Collect all metadata IDs that will be needed - const metadataIds = useMemo(() => { - const ids = new Set() - - if (programId) { - ids.add(programId) - } - if (programStageId) { - ids.add(programStageId) - } - - // Add IDs from itemIds processing - itemIds.forEach((id) => { - if ( - ouIdHelper.hasLevelPrefix(id) || - ouIdHelper.hasGroupPrefix(id) - ) { - ids.add(ouIdHelper.removePrefix(id)) - } else { - ids.add(extractPlainDimensionId(id)) - } - }) - - return Array.from(ids) - }, [programId, programStageId, itemIds]) - - // Get reactive metadata + const metadataIds = useMemo( + () => + [programId, programStageId].filter((id): id is string => + Boolean(id) + ), + [programId, programStageId] + ) const metadataItems = useMetadataItems(metadataIds) - // Compute the final data using reactive metadata - const { programName, stageName, itemDisplayNames } = useMemo(() => { - // Program and stage names + return useMemo(() => { const programMetadata = programId ? metadataItems[programId] : null const programStageMetadata = programStageId ? metadataItems[programStageId] @@ -82,52 +32,10 @@ export const useTooltipContentData = (dimension: LayoutDimension) => { ) : null const programStage = programStageMetadata ?? programStageFromProgram - const programName = programMetadata?.name ?? '' - const stageName = programStage?.name ?? '' - - // Item display names - const levelIds: Array = [] - const groupIds: Array = [] - const itemDisplayNames: Array = [] - - itemIds.forEach((id) => { - if (ouIdHelper.hasLevelPrefix(id)) { - levelIds.push(ouIdHelper.removePrefix(id)) - } else if (ouIdHelper.hasGroupPrefix(id)) { - groupIds.push(ouIdHelper.removePrefix(id)) - } else { - const plainId = extractPlainDimensionId(id) - itemDisplayNames.push( - isStartEndDate(plainId) - ? formatStartEndDate(plainId) - : (metadataItems[plainId]?.name ?? id) - ) - } - }) - - // Add level and group names - if (levelIds.length > 0) { - itemDisplayNames.push( - getNameList(levelIds, i18n.t('Levels'), metadataItems) - ) - } - - if (groupIds.length > 0) { - itemDisplayNames.push( - getNameList(groupIds, i18n.t('Groups'), metadataItems) - ) - } return { - programName, - stageName, - itemDisplayNames, + programName: programMetadata?.name ?? '', + stageName: programStage?.name ?? '', } - }, [programId, programStageId, itemIds, formatStartEndDate, metadataItems]) - - return { - programName, - stageName, - itemDisplayNames, - } + }, [programId, programStageId, metadataItems]) } diff --git a/src/components/layout-panel/use-layout-dimensions.ts b/src/components/layout-panel/use-layout-dimensions.ts index 231098366..eaa3b028e 100644 --- a/src/components/layout-panel/use-layout-dimensions.ts +++ b/src/components/layout-panel/use-layout-dimensions.ts @@ -4,6 +4,7 @@ import { useDimensionMetadataItems, useMetadataItems, } from '@hooks' +import { toLayoutDimension } from '@modules/dimension/layout-dimension' import { buildSuffixContext, getDimensionSuffix, @@ -99,28 +100,7 @@ export const useDimensionsWithSuffixes = ( throw new Error(`missing metadata for dimension ${id}`) } - const dimension: LayoutDimension = { - id, - name: metadataItem.name || id, - dimensionId: metadataItem.dimensionId ?? id, - dimensionType: metadataItem.dimensionType, - programStageId: metadataItem.programStageId, - programId: metadataItem.programId, - trackedEntityTypeId: metadataItem.trackedEntityTypeId, - suffix: getDimensionSuffix(metadataItem, context), - } - - if (metadataItem.optionSetId) { - dimension.optionSet = metadataItem.optionSetId - } - if (metadataItem.valueType) { - dimension.valueType = metadataItem.valueType - } - if (metadataItem.dimensionItemType) { - dimension.dimensionItemType = metadataItem.dimensionItemType - } - - acc[id] = dimension + acc[id] = toLayoutDimension(id, metadataItem, context) return acc }, {} diff --git a/src/components/line-list/__tests__/line-list.spec.tsx b/src/components/line-list/__tests__/line-list.spec.tsx index 93e8def8a..f08bd39b9 100644 --- a/src/components/line-list/__tests__/line-list.spec.tsx +++ b/src/components/line-list/__tests__/line-list.spec.tsx @@ -656,4 +656,111 @@ describe('LineList', () => { await user.click(sortButton) }) }) + + describe('title', () => { + /* The captured fixtures all carry the top-level program/programStage + * the old apps wrote, so the normaliser marks them legacy. Moving that + * context onto the dimensions is what a visualization saved by this + * app looks like. */ + const asCanonicalPayload = (fixture: LineListFixture) => { + const payload = fixture.eventVisualization as Record< + string, + unknown + > & { + program: { id: string } + programStage: { id: string } + columns: Array> + } + const canonical: Record = { ...payload } + delete canonical.program + delete canonical.programStage + delete canonical.legacy + + return { + ...canonical, + programDimensions: [payload.program], + columns: payload.columns.map((dimension) => ({ + ...dimension, + program: payload.program, + ...(dimension.dimension === 'ou' + ? { programStage: payload.programStage } + : {}), + })), + } + } + + it('renders no title for a legacy visualization that has none', async () => { + await renderLineList(simpleLineList) + + expect(screen.queryByText('Enrollments')).not.toBeInTheDocument() + }) + + it('renders a generated title for a non-legacy visualization', async () => { + await renderLineList(simpleLineList, { + eventVisualization: asCanonicalPayload(simpleLineList), + }) + + expect(await screen.findByText('Enrollments')).toBeInTheDocument() + }) + + it('renders the custom title of a legacy visualization that has one', async () => { + await renderLineList(simpleLineList, { + eventVisualization: { + ...(simpleLineList.eventVisualization as Record< + string, + unknown + >), + title: 'My saved title', + }, + }) + + expect( + await screen.findByText('My saved title') + ).toBeInTheDocument() + }) + + it('renders a filter line below the title for a filtered visualization', async () => { + const canonical = asCanonicalPayload(simpleLineList) + await renderLineList(simpleLineList, { + eventVisualization: { + ...canonical, + filters: [ + { + dimension: 'programStatus', + dimensionType: 'STATUS', + program: ( + simpleLineList.eventVisualization as { + program: { id: string } + } + ).program, + items: [{ id: 'ACTIVE' }], + }, + ], + }, + }) + + expect( + await screen.findByText(/Enrollment status: Active/) + ).toBeInTheDocument() + }) + + it('renders no filter line when there are no filters', async () => { + await renderLineList(simpleLineList, { + eventVisualization: asCanonicalPayload(simpleLineList), + }) + + expect(screen.queryByText(/Enrollment status/)).toBeNull() + }) + + it('renders no title when the title is hidden', async () => { + await renderLineList(simpleLineList, { + eventVisualization: { + ...asCanonicalPayload(simpleLineList), + hideTitle: true, + }, + }) + + expect(screen.queryByText('Enrollments')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/line-list/line-list.tsx b/src/components/line-list/line-list.tsx index 56a10a9bc..8d042c586 100644 --- a/src/components/line-list/line-list.tsx +++ b/src/components/line-list/line-list.tsx @@ -62,6 +62,7 @@ type LineListProps = { isInDashboard?: boolean isInModal?: boolean onColumnHeaderClick?: ColumnHeaderClickFn + filterText?: string } export const LineList: FC = (props) => @@ -82,6 +83,7 @@ const LineListInternal: FC = ({ isInDashboard = false, isInModal = false, onColumnHeaderClick, + filterText, }) => { const { isDisconnected } = useDhis2ConnectionStatus() const { headers, rows, pager, legendSets } = useTransformedLineListData( @@ -165,6 +167,12 @@ const LineListInternal: FC = ({ colSpan={colSpan} /> )} + {filterText && ( + + )} {headers.map((header) => ( ({ ...header, diff --git a/src/components/options/fields/__tests__/title.spec.tsx b/src/components/options/fields/__tests__/title.spec.tsx new file mode 100644 index 000000000..75708c8fe --- /dev/null +++ b/src/components/options/fields/__tests__/title.spec.tsx @@ -0,0 +1,113 @@ +import { Title } from '@components/options/fields/title' +import { + initialState as visUiConfigInitialState, + visUiConfigSlice, +} from '@store/vis-ui-config-slice' +import { renderWithReduxStoreProvider } from '@test-utils/render-with-redux-store-provider' +import { setupStore } from '@test-utils/setup-store' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { EventVisualizationOptions } from '@types' +import { describe, expect, it } from 'vitest' + +const setupTestStore = (options: Partial = {}) => + setupStore( + { [visUiConfigSlice.name]: visUiConfigSlice.reducer }, + { + visUiConfig: { + ...visUiConfigInitialState, + options: { ...visUiConfigInitialState.options, ...options }, + }, + } + ) + +const renderTitle = (options: Partial = {}) => { + const store = setupTestStore(options) + renderWithReduxStoreProvider(, store) + return { store } +} + +describe('Title', () => { + it('selects Auto generated when there is no title', () => { + renderTitle() + + expect(screen.getByLabelText('Auto generated')).toBeChecked() + expect(screen.queryByPlaceholderText('Add a title')).toBeNull() + }) + + it('selects Custom and shows the title when one is set', () => { + renderTitle({ title: 'Saved title' }) + + expect(screen.getByLabelText('Custom')).toBeChecked() + expect(screen.getByPlaceholderText('Add a title')).toHaveValue( + 'Saved title' + ) + }) + + it('selects None when the title is hidden', () => { + renderTitle({ hideTitle: true }) + + expect(screen.getByLabelText('None')).toBeChecked() + }) + + it('shows the text input only in Custom mode', async () => { + const user = userEvent.setup() + renderTitle() + + await user.click(screen.getByLabelText('Custom')) + + expect(screen.getByPlaceholderText('Add a title')).toBeInTheDocument() + }) + + it('stores the typed title and clears the hidden flag', async () => { + const user = userEvent.setup() + const { store } = renderTitle() + + await user.click(screen.getByLabelText('Custom')) + await user.type(screen.getByPlaceholderText('Add a title'), 'Mine') + + expect(store.getState().visUiConfig.options.title).toBe('Mine') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('clears the stored title when switching to Auto generated', async () => { + const user = userEvent.setup() + const { store } = renderTitle({ title: 'Saved title' }) + + await user.click(screen.getByLabelText('Auto generated')) + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('hides the title when switching to None', async () => { + const user = userEvent.setup() + const { store } = renderTitle({ title: 'Saved title' }) + + await user.click(screen.getByLabelText('None')) + + expect(store.getState().visUiConfig.options.hideTitle).toBe(true) + }) + + it('keeps the typed title when toggling away and back', async () => { + const user = userEvent.setup() + renderTitle() + + await user.click(screen.getByLabelText('Custom')) + await user.type(screen.getByPlaceholderText('Add a title'), 'Mine') + await user.click(screen.getByLabelText('Auto generated')) + await user.click(screen.getByLabelText('Custom')) + + expect(screen.getByPlaceholderText('Add a title')).toHaveValue('Mine') + }) + + it('leaves an empty Custom input stored as Auto generated', async () => { + const user = userEvent.setup() + const { store } = renderTitle() + + await user.click(screen.getByLabelText('Custom')) + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) +}) diff --git a/src/components/options/fields/title.tsx b/src/components/options/fields/title.tsx index 45aef7a24..2567d376f 100644 --- a/src/components/options/fields/title.tsx +++ b/src/components/options/fields/title.tsx @@ -1,19 +1,96 @@ import i18n from '@dhis2/d2-i18n' -import { type FC } from 'react' -import { TextBaseOption } from './text-base-option' +import { Field, InputField, Radio } from '@dhis2/ui' +import { useOptionsField } from '@hooks' +import { isPopulatedString } from '@modules/utils/guards' +import { useCallback, useState, type FC } from 'react' +import classes from './styles/option.module.css' + +type TitleMode = 'AUTO' | 'NONE' | 'CUSTOM' + +const getStoredMode = ( + title: string | undefined, + hideTitle: boolean | undefined +): TitleMode => { + if (hideTitle) { + return 'NONE' + } + return isPopulatedString(title) ? 'CUSTOM' : 'AUTO' +} type TitleProps = { label: string } -export const Title: FC<TitleProps> = ({ label }) => ( - <TextBaseOption - type="text" - inputWidth="280px" - label={label} - placeholder={i18n.t('Add a title')} - option={{ - name: 'title', - }} - /> -) +/* Auto is stored as an empty title, so a custom title typed and then toggled + * away from can't stay in the options without reading back as Custom. It is + * kept in local state instead, and dropped when the field unmounts. */ +export const Title: FC<TitleProps> = ({ label }) => { + const [title, setTitle] = useOptionsField('title') + const [hideTitle, setHideTitle] = useOptionsField('hideTitle') + + const [mode, setMode] = useState<TitleMode>(() => + getStoredMode(title, hideTitle) + ) + const [customTitle, setCustomTitle] = useState<string>(title ?? '') + + const onModeChange = useCallback( + (nextMode: TitleMode) => { + setMode(nextMode) + setHideTitle(nextMode === 'NONE') + setTitle(nextMode === 'CUSTOM' ? customTitle : '') + }, + [customTitle, setHideTitle, setTitle] + ) + + const onCustomTitleChange = useCallback( + (value: string) => { + setCustomTitle(value) + setTitle(value) + }, + [setTitle] + ) + + return ( + <Field name="title" label={label}> + <Radio + dense + name="titleMode" + label={i18n.t('Auto generated')} + checked={mode === 'AUTO'} + onChange={() => onModeChange('AUTO')} + dataTest="title-mode-auto" + /> + <Radio + dense + name="titleMode" + label={i18n.t('None')} + checked={mode === 'NONE'} + onChange={() => onModeChange('NONE')} + dataTest="title-mode-none" + /> + <Radio + dense + name="titleMode" + label={i18n.t('Custom')} + checked={mode === 'CUSTOM'} + onChange={() => onModeChange('CUSTOM')} + dataTest="title-mode-custom" + /> + {mode === 'CUSTOM' && ( + <div className={classes.optionToggleable}> + <InputField + dense + name="title" + inputWidth="280px" + placeholder={i18n.t('Add a title')} + value={customTitle} + onChange={({ value }) => + onCustomTitleChange(value ?? '') + } + dataTest="title-input" + /> + </div> + )} + </Field> + ) +} diff --git a/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx b/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx new file mode 100644 index 000000000..0677fc2f4 --- /dev/null +++ b/src/components/plugin-wrapper/__tests__/pivot-table-plugin.spec.tsx @@ -0,0 +1,75 @@ +import { MockMetadataProvider } from '@components/app-wrapper/metadata-provider/metadata-provider' +import { PivotTablePlugin } from '@components/plugin-wrapper/pivot-table-plugin' +import { render, screen } from '@testing-library/react' +import type { CurrentVisualization } from '@types' +import { describe, expect, it, vi } from 'vitest' + +/* The engine renders the filter row itself, so the only thing this app + * controls is the prop. Stubbing the table keeps the assertion on that. */ +vi.mock('@dhis2/analytics', async (importOriginal) => ({ + ...(await importOriginal<object>()), + PivotTable: ({ filterText }: { filterText?: string }) => ( + <div data-test="pivot-table" data-filter-text={filterText} /> + ), +})) + +vi.mock( + '@components/plugin-wrapper/hooks/use-pivot-table-analytics-data', + () => ({ + usePivotTableAnalyticsData: () => [ + vi.fn(), + { + isFetching: false, + data: { headers: [], rows: [], metaData: {} }, + }, + ], + }) +) + +const visualization = { + type: 'PIVOT_TABLE', + outputType: 'EVENT', + columns: [], + rows: [], + filters: [], +} as unknown as CurrentVisualization + +describe('PivotTablePlugin', () => { + it('hands the filter text to the pivot table', () => { + render( + <MockMetadataProvider> + <PivotTablePlugin + displayProperty="name" + visualization={visualization} + isInDashboard={false} + isInModal={false} + onResponseReceived={vi.fn()} + filterText="Gender: Female" + /> + </MockMetadataProvider> + ) + + expect(screen.getByTestId('pivot-table')).toHaveAttribute( + 'data-filter-text', + 'Gender: Female' + ) + }) + + it('passes no filter text when there is none', () => { + render( + <MockMetadataProvider> + <PivotTablePlugin + displayProperty="name" + visualization={visualization} + isInDashboard={false} + isInModal={false} + onResponseReceived={vi.fn()} + /> + </MockMetadataProvider> + ) + + expect(screen.getByTestId('pivot-table')).not.toHaveAttribute( + 'data-filter-text' + ) + }) +}) diff --git a/src/components/plugin-wrapper/line-list-plugin.tsx b/src/components/plugin-wrapper/line-list-plugin.tsx index 43b2eb6c3..ddc6afa63 100644 --- a/src/components/plugin-wrapper/line-list-plugin.tsx +++ b/src/components/plugin-wrapper/line-list-plugin.tsx @@ -22,6 +22,7 @@ type LineListPluginProps = { onColumnHeaderClick?: ColumnHeaderClickFn onDataSorted?: (sorting: InternalSorting) => void onResponseReceived: () => void + filterText?: string } export const LineListPlugin: FC<LineListPluginProps> = ({ @@ -33,6 +34,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ onColumnHeaderClick, onDataSorted, onResponseReceived, + filterText, }) => { const [fetchAnalyticsData, { data, isFetching }] = useLineListAnalyticsData() @@ -60,7 +62,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ const onPaginate = useCallback<PaginateFn>( ({ page, pageSize }) => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest( eventVisualization @@ -101,7 +103,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ ) useEffect(() => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest(eventVisualization), relativePeriodDate, @@ -125,6 +127,7 @@ export const LineListPlugin: FC<LineListPluginProps> = ({ return ( <LineList + filterText={filterText} analyticsData={data} onDataSort={onDataSort} onPaginate={onPaginate} diff --git a/src/components/plugin-wrapper/pivot-table-plugin.tsx b/src/components/plugin-wrapper/pivot-table-plugin.tsx index 96c66e341..802f48d94 100644 --- a/src/components/plugin-wrapper/pivot-table-plugin.tsx +++ b/src/components/plugin-wrapper/pivot-table-plugin.tsx @@ -38,6 +38,7 @@ type PivotTablePluginProps = { isInDashboard: boolean isInModal: boolean onResponseReceived: () => void + filterText?: string } export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ @@ -45,6 +46,7 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ visualization, relativePeriodDate, onResponseReceived, + filterText, // eslint-disable-next-line @typescript-eslint/no-unused-vars isInDashboard, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -64,7 +66,7 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ // TODO: implement onDataSorted and any other function/callback that cannot rely on the Redux store useEffect(() => { - fetchAnalyticsData({ + void fetchAnalyticsData({ visualization: transformVisualizationForAnalyticsRequest(visualization), relativePeriodDate, @@ -86,5 +88,11 @@ export const PivotTablePlugin: FC<PivotTablePluginProps> = ({ return null } - return <PivotTable visualization={eventVisualization} data={data} /> + return ( + <PivotTable + visualization={eventVisualization} + data={data} + filterText={filterText} + /> + ) } diff --git a/src/components/plugin-wrapper/plugin-wrapper.tsx b/src/components/plugin-wrapper/plugin-wrapper.tsx index 893a4d049..b19d31e5c 100644 --- a/src/components/plugin-wrapper/plugin-wrapper.tsx +++ b/src/components/plugin-wrapper/plugin-wrapper.tsx @@ -1,10 +1,14 @@ import type { EngineError } from '@api/parse-engine-error' +import { useMetadataStore } from '@components/app-wrapper/metadata-provider/metadata-provider' import { CanvasError } from '@components/canvas-error/canvas-error' import { CanvasErrorFallback } from '@components/canvas-error/canvas-error-fallback' import type { ColumnHeaderClickFn } from '@components/line-list/types' +import { useCurrentUserInfo } from '@dhis2/app-runtime' import { Center, CircularLoader } from '@dhis2/ui' import { assertNever } from '@modules/utils/guards' +import { getVisualizationFilterText } from '@modules/visualization/filter-text' import { isVisualizationEmpty } from '@modules/visualization/guards' +import { getVisualizationTitle } from '@modules/visualization/title' import type { CurrentUser, CurrentVisualization, @@ -85,6 +89,35 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ [visualization, relativePeriodDate] ) + const metadataStore = useMetadataStore() + const locale = useCurrentUserInfo()?.settings?.keyUiLocale + + const visualizationWithTitle = useMemo( + () => + isVisualizationEmpty(visualization) + ? visualization + : { + ...visualization, + title: getVisualizationTitle( + visualization, + metadataStore + ), + }, + [visualization, metadataStore] + ) + + const filterText = useMemo( + () => + isVisualizationEmpty(visualization) + ? undefined + : getVisualizationFilterText({ + visualization, + metadataStore, + locale, + }), + [visualization, metadataStore, locale] + ) + const [hasAnalyticsData, setHasAnalyticsData] = useState(false) const onResponseReceived = useCallback(() => { @@ -119,7 +152,7 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ ) } - if (isVisualizationEmpty(visualization)) { + if (isVisualizationEmpty(visualizationWithTitle)) { return null } @@ -142,10 +175,11 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ filters={filters} isLoading={isVisualizationLoading || !hasAnalyticsData} /> - {visualization.type === 'LINE_LIST' && ( + {visualizationWithTitle.type === 'LINE_LIST' && ( <LineListPlugin displayProperty={displayProperty} - visualization={visualization} + visualization={visualizationWithTitle} + filterText={filterText} relativePeriodDate={relativePeriodDate} isInDashboard={isInDashboard} isInModal={isInModal} @@ -154,10 +188,11 @@ export const PluginWrapper: FC<PluginWrapperProps> = ({ onResponseReceived={onResponseReceived} /> )} - {visualization.type === 'PIVOT_TABLE' && ( + {visualizationWithTitle.type === 'PIVOT_TABLE' && ( <PivotTablePlugin displayProperty={displayProperty} - visualization={visualization} + visualization={visualizationWithTitle} + filterText={filterText} relativePeriodDate={relativePeriodDate} isInDashboard={isInDashboard} isInModal={isInModal} diff --git a/src/hooks/__tests__/use-conditions-texts.spec.ts b/src/hooks/__tests__/use-conditions-texts.spec.ts index bfcbf4351..adba50ccc 100644 --- a/src/hooks/__tests__/use-conditions-texts.spec.ts +++ b/src/hooks/__tests__/use-conditions-texts.spec.ts @@ -4,7 +4,7 @@ import { useAddMetadata } from '@hooks' import { act, renderHook } from '@testing-library/react' import { createElement } from 'react' import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { useConditionsTexts } from '../use-conditions-texts' const mockRootOrgUnits = [ @@ -21,46 +21,6 @@ vi.mock('@hooks', async () => ({ useRootOrgUnits: () => mockRootOrgUnits, })) -const { - mockParseConditionsStringToArray, - mockShouldUseLegendSetConditions, - mockShouldUseOrgUnitConditions, - mockShouldUseOptionSetConditions, - mockShouldUseBooleanConditions, - mockGetLegendSetConditionMetadataIds, - mockGetOptionSetIdAndSelectedOptionCodes, - mockGetBooleanConditionTexts, - mockGetOrgUnitConditionMetadataIds, - mockGetOperatorConditionTexts, -} = vi.hoisted(() => ({ - mockParseConditionsStringToArray: vi.fn(), - mockShouldUseLegendSetConditions: vi.fn(), - mockShouldUseOrgUnitConditions: vi.fn(), - mockShouldUseOptionSetConditions: vi.fn(), - mockShouldUseBooleanConditions: vi.fn(), - mockGetLegendSetConditionMetadataIds: vi.fn(), - mockGetOptionSetIdAndSelectedOptionCodes: vi.fn(), - mockGetBooleanConditionTexts: vi.fn(), - mockGetOrgUnitConditionMetadataIds: vi.fn(), - mockGetOperatorConditionTexts: vi.fn(), -})) - -vi.mock('@modules/conditions', () => ({ - parseConditionsStringToArray: mockParseConditionsStringToArray, - shouldUseLegendSetConditions: mockShouldUseLegendSetConditions, - shouldUseOrgUnitConditions: mockShouldUseOrgUnitConditions, - shouldUseOptionSetConditions: mockShouldUseOptionSetConditions, - shouldUseBooleanConditions: mockShouldUseBooleanConditions, - getLegendSetConditionMetadataIds: mockGetLegendSetConditionMetadataIds, - getOptionSetIdAndSelectedOptionCodes: - mockGetOptionSetIdAndSelectedOptionCodes, - getBooleanConditionTexts: mockGetBooleanConditionTexts, - getOrgUnitConditionMetadataIds: mockGetOrgUnitConditionMetadataIds, - getOperatorConditionTexts: mockGetOperatorConditionTexts, - NO_VALUE_OPTION_CODE: 'D2__NOVALUE', - getNoValueOptionName: () => 'No value', -})) - type WrapperProps = { children: ReactNode } const DefaultWrapper = ({ children }: WrapperProps) => createElement(MockMetadataProvider, undefined, children) @@ -72,44 +32,17 @@ const baseDimension: LayoutDimension = { name: 'Test dimension', } -const setupDefaultMocks = () => { - mockParseConditionsStringToArray.mockImplementation((value) => { - if (Array.isArray(value)) { - return value - } - if (typeof value === 'string' && value.length > 0) { - return value.split(',') - } - return [] - }) - mockShouldUseLegendSetConditions.mockReturnValue(false) - mockShouldUseOrgUnitConditions.mockReturnValue(false) - mockShouldUseOptionSetConditions.mockReturnValue(false) - mockShouldUseBooleanConditions.mockReturnValue(false) - mockGetLegendSetConditionMetadataIds.mockReturnValue([]) - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId: '', - selectedOptionCodes: [], - }) - mockGetBooleanConditionTexts.mockReturnValue([]) - mockGetOrgUnitConditionMetadataIds.mockReturnValue([]) - mockGetOperatorConditionTexts.mockReturnValue([]) -} - -beforeEach(() => { - setupDefaultMocks() -}) - describe('useConditionsTexts metadata updates', () => { it('replaces legend set ids with metadata names as they arrive', () => { - mockShouldUseLegendSetConditions.mockReturnValue(true) const legendIds = ['LEGEND_A', 'LEGEND_B'] - mockGetLegendSetConditionMetadataIds.mockReturnValue(legendIds) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'gt:10' }, + conditions: { + condition: 'IN:LEGEND_A;LEGEND_B', + legendSet: 'LEGEND_SET', + }, dimension: baseDimension, formatValueOptions: {}, }) @@ -141,18 +74,13 @@ describe('useConditionsTexts metadata updates', () => { }) it('names option set condition texts as their metadata arrives, falling back to the code', () => { - mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_123' const selectedOptionCodes = ['A', 'B'] - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId, - selectedOptionCodes, - }) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:A;B' }, + conditions: { condition: 'IN:A;B' }, dimension: { ...baseDimension, optionSet: optionSetId }, formatValueOptions: {}, }) @@ -189,20 +117,12 @@ describe('useConditionsTexts metadata updates', () => { }) it('prefers progressively richer organisation unit metadata as it arrives', () => { - mockShouldUseOrgUnitConditions.mockReturnValue(true) const prefixedOrgUnitIds = ['LEVEL-OU_A', 'LEVEL-OU_B'] - const unprefixedOrgUnitIds = ['OU_A', 'OU_B'] - mockGetOrgUnitConditionMetadataIds.mockImplementation( - (_, includeUnprefixed = false) => - includeUnprefixed - ? [...prefixedOrgUnitIds, ...unprefixedOrgUnitIds] - : prefixedOrgUnitIds - ) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:LEVEL-OU_A;LEVEL-OU_B' }, + conditions: { condition: 'IN:LEVEL-OU_A;LEVEL-OU_B' }, dimension: { ...baseDimension, valueType: 'ORGANISATION_UNIT', @@ -248,19 +168,12 @@ describe('useConditionsTexts metadata updates', () => { }) it('mixes available org unit metadata on a per-id basis', () => { - mockShouldUseOrgUnitConditions.mockReturnValue(true) const prefixedOrgUnitIds = ['LEVEL-OU_A', 'LEVEL-OU_B'] - mockGetOrgUnitConditionMetadataIds.mockImplementation( - (_, includeUnprefixed = false) => - includeUnprefixed - ? [...prefixedOrgUnitIds, 'OU_B'] - : prefixedOrgUnitIds - ) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:LEVEL-OU_A;LEVEL-OU_B' }, + conditions: { condition: 'IN:LEVEL-OU_A;LEVEL-OU_B' }, dimension: { ...baseDimension, valueType: 'ORGANISATION_UNIT', @@ -290,18 +203,13 @@ describe('useConditionsTexts metadata updates', () => { }) it('only returns option texts for selected option codes', () => { - mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_EXTRA' const selectedOptionCodes = ['A', 'B'] - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId, - selectedOptionCodes, - }) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:A;B' }, + conditions: { condition: 'IN:A;B' }, dimension: { ...baseDimension, optionSet: optionSetId }, formatValueOptions: {}, }) @@ -329,17 +237,12 @@ describe('useConditionsTexts metadata updates', () => { }) it('keeps the selected order and names the no-value option without metadata', () => { - mockShouldUseOptionSetConditions.mockReturnValue(true) const optionSetId = 'OS_ORDER' - mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({ - optionSetId, - selectedOptionCodes: ['B', 'D2__NOVALUE', 'A'], - }) const { result } = renderHook( () => { const texts = useConditionsTexts({ - conditions: { condition: 'in:B;D2__NOVALUE;A' }, + conditions: { condition: 'IN:B;D2__NOVALUE;A' }, dimension: { ...baseDimension, optionSet: optionSetId }, formatValueOptions: {}, }) diff --git a/src/hooks/__tests__/use-dimension-value-texts.spec.ts b/src/hooks/__tests__/use-dimension-value-texts.spec.ts new file mode 100644 index 000000000..508f33dfa --- /dev/null +++ b/src/hooks/__tests__/use-dimension-value-texts.spec.ts @@ -0,0 +1,41 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { useAddMetadata, useDimensionValueTexts } from '@hooks' +import { renderHookWithAppWrapper } from '@test-utils/app-wrapper' +import { act } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +const orgUnit: LayoutDimension = { + id: 'stg.ou', + dimensionId: 'ou', + dimensionType: 'ORGANISATION_UNIT', + name: 'Org unit', +} +const itemIds = ['OU1', 'LEVEL-lvl1'] +const conditions = {} +const formatValueOptions = {} + +describe('useDimensionValueTexts', () => { + it('swaps in names as their metadata arrives', async () => { + const { result } = await renderHookWithAppWrapper(() => ({ + texts: useDimensionValueTexts({ + dimension: orgUnit, + itemIds, + conditions, + formatValueOptions, + }), + addMetadata: useAddMetadata(), + })) + + expect(result.current.texts).toEqual(['OU1', 'Levels: lvl1']) + + act(() => { + result.current.addMetadata({ uid: 'OU1', name: 'Sierra Leone' }) + result.current.addMetadata({ uid: 'lvl1', name: 'National' }) + }) + + expect(result.current.texts).toEqual([ + 'Sierra Leone', + 'Levels: National', + ]) + }) +}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 19e1bac19..b26a7cdc0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -36,6 +36,7 @@ export { export * from './use-rtk-query' export * from './use-rtk-lazy-query' export * from './use-conditions-texts' +export * from './use-dimension-value-texts' export * from './use-options-field' export * from './use-stable-callback' export * from './use-layout-context' diff --git a/src/hooks/use-conditions-texts.ts b/src/hooks/use-conditions-texts.ts index ac7f0bb8b..eef138977 100644 --- a/src/hooks/use-conditions-texts.ts +++ b/src/hooks/use-conditions-texts.ts @@ -1,35 +1,13 @@ import type { LayoutDimension } from '@components/layout-panel/axis/chip' -import { ouIdHelper } from '@dhis2/analytics' import { useMetadataItems } from '@hooks' import { - parseConditionsStringToArray, - shouldUseLegendSetConditions, - shouldUseOptionSetConditions, - shouldUseBooleanConditions, - shouldUseOrgUnitConditions, - getLegendSetConditionMetadataIds, - getOptionSetIdAndSelectedOptionCodes, - getBooleanConditionTexts, - getOrgUnitConditionMetadataIds, - getOperatorConditionTexts, - getNoValueOptionName, - NO_VALUE_OPTION_CODE, + getConditionsMetadataIds, + getConditionsTexts, + type Conditions, + type FormatValueOptions, } from '@modules/conditions' -import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' -import type { SavedVisualization } from '@types' import { useMemo } from 'react' -type Conditions = { - condition?: string | string[] - legendSet?: string -} - -type FormatValueOptions = { - locale?: string - digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] - baseUrl?: string -} - type UseConditionsTextsParams = { conditions: Conditions dimension: LayoutDimension @@ -41,89 +19,20 @@ export const useConditionsTexts = ({ dimension, formatValueOptions, }: UseConditionsTextsParams): string[] => { - const conditionsList = useMemo( - () => parseConditionsStringToArray(conditions?.condition ?? ''), - [conditions?.condition] + const metadataIds = useMemo( + () => getConditionsMetadataIds({ conditions, dimension }), + [conditions, dimension] ) - const metadataIds = useMemo(() => { - if (shouldUseLegendSetConditions(conditions)) { - return getLegendSetConditionMetadataIds(conditions, conditionsList) - } - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - return getOrgUnitConditionMetadataIds(conditionsList, true) - } - if ( - shouldUseOptionSetConditions(conditions, dimension, conditionsList) - ) { - const { optionSetId } = getOptionSetIdAndSelectedOptionCodes( - dimension, - conditionsList - ) - return [optionSetId] - } - - return [] - }, [conditionsList, conditions, dimension]) const metadataItems = useMetadataItems(metadataIds) - const names = useMemo(() => { - if (shouldUseLegendSetConditions(conditions)) { - // Prefer name, fallback to ID - return metadataIds.map((id) => metadataItems[id]?.name ?? id) - } - if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { - // Prefer name from original ID, fallback to unprefixed ID name, then ID - const idsWithoutUnprefixed = getOrgUnitConditionMetadataIds( - conditionsList, - false - ) - return idsWithoutUnprefixed.map((id) => { - const metadataItem = metadataItems[id] - if (metadataItem?.name) { - return metadataItem.name - } - // Try unprefixed version if original has no name - const unprefixedId = ouIdHelper.removePrefix(id) - const unprefixedMetadataItem = metadataItems[unprefixedId] - return unprefixedMetadataItem?.name ?? id - }) - } - if ( - shouldUseOptionSetConditions(conditions, dimension, conditionsList) - ) { - const { optionSetId, selectedOptionCodes } = - getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) - const optionSetMetadata = metadataItems[optionSetId] - - const optionNamesByCode = new Map( - isOptionSetMetadataItem(optionSetMetadata) - ? optionSetMetadata.options.map((option) => [ - option.code, - option.name, - ]) - : [] - ) - optionNamesByCode.set(NO_VALUE_OPTION_CODE, getNoValueOptionName()) - - return selectedOptionCodes.map( - (code) => optionNamesByCode.get(code) ?? code - ) - } - if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { - return getBooleanConditionTexts(conditionsList) - } - return getOperatorConditionTexts( - dimension, - conditionsList, - formatValueOptions - ) - }, [ - conditions, - dimension, - conditionsList, - metadataIds, - metadataItems, - formatValueOptions, - ]) - return names + return useMemo( + () => + getConditionsTexts({ + conditions, + dimension, + formatValueOptions, + metadataItems, + }), + [conditions, dimension, formatValueOptions, metadataItems] + ) } diff --git a/src/hooks/use-dimension-value-texts.ts b/src/hooks/use-dimension-value-texts.ts new file mode 100644 index 000000000..c9ced7a64 --- /dev/null +++ b/src/hooks/use-dimension-value-texts.ts @@ -0,0 +1,50 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { useMetadataItems } from '@hooks' +import type { Conditions, FormatValueOptions } from '@modules/conditions' +import { + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' +import { useLocalizedStartEndDateFormatter } from '@modules/utils/dates' +import { useMemo } from 'react' + +type UseDimensionValueTextsParams = { + dimension: LayoutDimension + itemIds: string[] + conditions: Conditions + formatValueOptions: FormatValueOptions +} + +export const useDimensionValueTexts = ({ + dimension, + itemIds, + conditions, + formatValueOptions, +}: UseDimensionValueTextsParams): string[] => { + const formatStartEndDate = useLocalizedStartEndDateFormatter() + const metadataIds = useMemo( + () => getDimensionValueMetadataIds({ dimension, itemIds, conditions }), + [dimension, itemIds, conditions] + ) + const metadataItems = useMetadataItems(metadataIds) + + return useMemo( + () => + getDimensionValueTexts({ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, + }), + [ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, + ] + ) +} diff --git a/src/modules/__tests__/query.spec.ts b/src/modules/__tests__/query.spec.ts new file mode 100644 index 000000000..37e1138a2 --- /dev/null +++ b/src/modules/__tests__/query.spec.ts @@ -0,0 +1,27 @@ +import { getProgramFields, getTrackedEntityTypeFields } from '@modules/query' +import { describe, expect, it } from 'vitest' + +describe('getProgramFields', () => { + it('requests both singular and plural event and enrollment labels', () => { + const fields = getProgramFields('displayName').split(',') + + expect(fields).toContain('displayEventLabel') + expect(fields).toContain('displayEventsLabel') + expect(fields).toContain('displayEnrollmentLabel') + expect(fields).toContain('displayEnrollmentsLabel') + }) + + it('requests the plural label on the nested tracked entity type', () => { + expect(getProgramFields('displayName')).toContain( + 'trackedEntityType[id,displayName~rename(name),displayTrackedEntityTypesLabel]' + ) + }) +}) + +describe('getTrackedEntityTypeFields', () => { + it('requests the plural tracked entity type label', () => { + expect(getTrackedEntityTypeFields('displayName').split(',')).toContain( + 'displayTrackedEntityTypesLabel' + ) + }) +}) diff --git a/src/modules/conditions.ts b/src/modules/conditions.ts index c0dc1904d..6c5452a35 100644 --- a/src/modules/conditions.ts +++ b/src/modules/conditions.ts @@ -3,18 +3,20 @@ import { formatValue, ouIdHelper } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { getCompoundDimensionId } from '@modules/dimension/ids' import { combineAllDimensionsFromVisualization } from '@modules/dimension/translation' +import { isOptionSetMetadataItem } from '@modules/metadata/item-guards' import type { CurrentVisualization, + MetadataItem, OutputType, SavedVisualization, ValueType, } from '@types' -type Conditions = { +export type Conditions = { condition?: string | string[] legendSet?: string } -type FormatValueOptions = { +export type FormatValueOptions = { locale?: string digitGroupSeparator?: SavedVisualization['digitGroupSeparator'] baseUrl?: string @@ -465,3 +467,92 @@ export const getConditionsFromVisualization = ( return result } + +export const getConditionsMetadataIds = ({ + conditions, + dimension, +}: { + conditions: Conditions + dimension: LayoutDimension +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList) + } + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + return getOrgUnitConditionMetadataIds(conditionsList, true) + } + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + return [ + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + .optionSetId, + ] + } + return [] +} + +export const getConditionsTexts = ({ + conditions, + dimension, + formatValueOptions, + metadataItems, +}: { + conditions: Conditions + dimension: LayoutDimension + formatValueOptions: FormatValueOptions + metadataItems: Record<string, MetadataItem | undefined> +}): string[] => { + const conditionsList = parseConditionsStringToArray( + conditions?.condition ?? '' + ) + + if (shouldUseLegendSetConditions(conditions)) { + return getLegendSetConditionMetadataIds(conditions, conditionsList).map( + (id) => metadataItems[id]?.name ?? id + ) + } + + if (shouldUseOrgUnitConditions(conditions, dimension, conditionsList)) { + /* Passed `false` so the prefixed ID is returned: the prefixed entry is + * preferred for naming, with the unprefixed one as the fallback. */ + return getOrgUnitConditionMetadataIds(conditionsList, false).map( + (id) => + metadataItems[id]?.name ?? + metadataItems[ouIdHelper.removePrefix(id)]?.name ?? + id + ) + } + + if (shouldUseOptionSetConditions(conditions, dimension, conditionsList)) { + const { optionSetId, selectedOptionCodes } = + getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList) + const optionSetMetadata = metadataItems[optionSetId] + + const optionNamesByCode = new Map( + isOptionSetMetadataItem(optionSetMetadata) + ? optionSetMetadata.options.map((option) => [ + option.code, + option.name, + ]) + : [] + ) + optionNamesByCode.set(NO_VALUE_OPTION_CODE, getNoValueOptionName()) + + return selectedOptionCodes.map( + (code) => optionNamesByCode.get(code) ?? code + ) + } + + if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) { + return getBooleanConditionTexts(conditionsList) + } + + return getOperatorConditionTexts( + dimension, + conditionsList, + formatValueOptions + ) +} diff --git a/src/modules/dimension/__tests__/item-names.spec.ts b/src/modules/dimension/__tests__/item-names.spec.ts new file mode 100644 index 000000000..4caac719d --- /dev/null +++ b/src/modules/dimension/__tests__/item-names.spec.ts @@ -0,0 +1,76 @@ +import { + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import type { MetadataItem } from '@types' +import { describe, expect, it } from 'vitest' + +const formatStartEndDate = getStartEndDateFormatter('en') + +const named = (names: Record<string, string>) => + Object.fromEntries( + Object.entries(names).map(([id, name]) => [ + id, + { id, name } as MetadataItem, + ]) + ) + +describe('getItemMetadataIds', () => { + it('strips level and group prefixes so the names can be looked up', () => { + expect( + getItemMetadataIds(['OU1', 'LEVEL-lvl1', 'OU_GROUP-grp1']) + ).toEqual(['OU1', 'lvl1', 'grp1']) + }) +}) + +describe('getItemDisplayNames', () => { + it('names items from the metadata', () => { + expect( + getItemDisplayNames({ + itemIds: ['OU1', 'OU2'], + metadataItems: named({ OU1: 'Sierra Leone', OU2: 'Bo' }), + formatStartEndDate, + }) + ).toEqual(['Sierra Leone', 'Bo']) + }) + + it('shows the id of an item whose name has not loaded yet', () => { + expect( + getItemDisplayNames({ + itemIds: ['OU1'], + metadataItems: {}, + formatStartEndDate, + }) + ).toEqual(['OU1']) + }) + + it('collapses levels and groups into one entry each, after the items', () => { + expect( + getItemDisplayNames({ + itemIds: ['LEVEL-lvl1', 'OU1', 'OU_GROUP-grp1', 'LEVEL-lvl2'], + metadataItems: named({ + OU1: 'Sierra Leone', + lvl1: 'National', + lvl2: 'District', + grp1: 'Group One', + }), + formatStartEndDate, + }) + ).toEqual([ + 'Sierra Leone', + 'Levels: National, District', + 'Groups: Group One', + ]) + }) + + it('formats a custom start and end date', () => { + expect( + getItemDisplayNames({ + itemIds: ['2023-01-01_2023-12-31'], + metadataItems: {}, + formatStartEndDate, + }) + ).toEqual(['January 1, 2023 - December 31, 2023']) + }) +}) diff --git a/src/modules/dimension/__tests__/value-texts.spec.ts b/src/modules/dimension/__tests__/value-texts.spec.ts new file mode 100644 index 000000000..8c192ca84 --- /dev/null +++ b/src/modules/dimension/__tests__/value-texts.spec.ts @@ -0,0 +1,82 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import type { MetadataItem } from '@types' +import { describe, expect, it } from 'vitest' + +const orgUnit: LayoutDimension = { + id: 'stg.ou', + dimensionId: 'ou', + dimensionType: 'ORGANISATION_UNIT', + name: 'Org unit', +} + +const age: LayoutDimension = { + id: 'stg.age', + dimensionId: 'age', + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + name: 'Age', +} + +const textsFor = ( + dimension: LayoutDimension, + values: { itemIds?: string[]; condition?: string } +) => + getDimensionValueTexts({ + dimension, + itemIds: values.itemIds ?? [], + conditions: { condition: values.condition }, + metadataItems: { + OU1: { id: 'OU1', name: 'Sierra Leone' } as MetadataItem, + }, + formatValueOptions: {}, + formatStartEndDate: getStartEndDateFormatter('en'), + }) + +describe('getDimensionValueTexts', () => { + it('names the selected items of an item-based dimension', () => { + expect(textsFor(orgUnit, { itemIds: ['OU1'] })).toEqual([ + 'Sierra Leone', + ]) + }) + + it('ignores conditions on an item-based dimension', () => { + expect(textsFor(orgUnit, { condition: 'GT:20' })).toEqual([]) + }) + + it('describes the conditions of a value-based dimension', () => { + const [text] = textsFor(age, { condition: 'GT:20' }) + + expect(text).toContain('20') + }) + + it('ignores items on a value-based dimension', () => { + expect(textsFor(age, { itemIds: ['OU1'] })).toEqual([]) + }) +}) + +describe('getDimensionValueMetadataIds', () => { + it('asks for the item names of an item-based dimension', () => { + expect( + getDimensionValueMetadataIds({ + dimension: orgUnit, + itemIds: ['OU1', 'LEVEL-lvl1'], + conditions: {}, + }) + ).toEqual(['OU1', 'lvl1']) + }) + + it('asks for the legend names of a grouped value-based dimension', () => { + expect( + getDimensionValueMetadataIds({ + dimension: age, + itemIds: [], + conditions: { legendSet: 'LS', condition: 'IN:L1;L2' }, + }) + ).toEqual(['L1', 'L2']) + }) +}) diff --git a/src/modules/dimension/dimension-type.ts b/src/modules/dimension/dimension-type.ts index 8f8b800a7..64f401d71 100644 --- a/src/modules/dimension/dimension-type.ts +++ b/src/modules/dimension/dimension-type.ts @@ -20,3 +20,17 @@ export const getUiDimensionType = ( return dimensionType } } + +const ITEM_BASED_DIMENSION_TYPES: ReadonlySet<DimensionType> = new Set([ + 'CATEGORY', + 'CATEGORY_OPTION_GROUP_SET', + 'ORGANISATION_UNIT_GROUP_SET', + 'STATUS', + 'PERIOD', + 'ORGANISATION_UNIT', +]) + +/* Item-based dimensions are narrowed by selecting items; the rest by value + * conditions. */ +export const isItemBasedDimensionType = (dimensionType: DimensionType) => + ITEM_BASED_DIMENSION_TYPES.has(dimensionType) diff --git a/src/modules/dimension/item-names.ts b/src/modules/dimension/item-names.ts new file mode 100644 index 000000000..5d5b12673 --- /dev/null +++ b/src/modules/dimension/item-names.ts @@ -0,0 +1,72 @@ +import { ouIdHelper } from '@dhis2/analytics' +import i18n from '@dhis2/d2-i18n' +import { extractPlainDimensionId } from '@modules/dimension/ids' +import { isStartEndDate } from '@modules/utils/dates' +import type { MetadataItem } from '@types' + +type ItemMetadataItems = Record<string, MetadataItem | undefined> + +export const getItemMetadataIds = (itemIds: string[]): string[] => { + const ids = new Set<string>() + + for (const id of itemIds) { + ids.add( + ouIdHelper.hasLevelPrefix(id) || ouIdHelper.hasGroupPrefix(id) + ? ouIdHelper.removePrefix(id) + : extractPlainDimensionId(id) + ) + } + + return Array.from(ids) +} + +const getNameList = ( + ids: string[], + label: string, + metadataItems: ItemMetadataItems +): string => + `${label}: ${ids.map((id) => metadataItems[id]?.name ?? id).join(', ')}` + +/* Org unit levels and groups are collapsed into one "Levels: a, b" entry each, + * rather than listed alongside the individually selected org units. */ +export const getItemDisplayNames = ({ + itemIds, + metadataItems, + formatStartEndDate, +}: { + itemIds: string[] + metadataItems: ItemMetadataItems + formatStartEndDate: (startEndDate: string) => string +}): string[] => { + const levelIds: string[] = [] + const groupIds: string[] = [] + const displayNames: string[] = [] + + for (const id of itemIds) { + if (ouIdHelper.hasLevelPrefix(id)) { + levelIds.push(ouIdHelper.removePrefix(id)) + } else if (ouIdHelper.hasGroupPrefix(id)) { + groupIds.push(ouIdHelper.removePrefix(id)) + } else { + const plainId = extractPlainDimensionId(id) + displayNames.push( + isStartEndDate(plainId) + ? formatStartEndDate(plainId) + : (metadataItems[plainId]?.name ?? id) + ) + } + } + + if (levelIds.length > 0) { + displayNames.push( + getNameList(levelIds, i18n.t('Levels'), metadataItems) + ) + } + if (groupIds.length > 0) { + displayNames.push( + getNameList(groupIds, i18n.t('Groups'), metadataItems) + ) + } + + return displayNames +} diff --git a/src/modules/dimension/layout-dimension.ts b/src/modules/dimension/layout-dimension.ts new file mode 100644 index 000000000..ff8d0b709 --- /dev/null +++ b/src/modules/dimension/layout-dimension.ts @@ -0,0 +1,35 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getDimensionSuffix, + type SuffixContext, +} from '@modules/dimension/suffix' +import type { DimensionMetadataItem } from '@types' + +export const toLayoutDimension = ( + id: string, + metadataItem: DimensionMetadataItem, + suffixContext: SuffixContext +): LayoutDimension => { + const dimension: LayoutDimension = { + id, + name: metadataItem.name, + dimensionId: metadataItem.dimensionId, + dimensionType: metadataItem.dimensionType, + programStageId: metadataItem.programStageId, + programId: metadataItem.programId, + trackedEntityTypeId: metadataItem.trackedEntityTypeId, + suffix: getDimensionSuffix(metadataItem, suffixContext), + } + + if (metadataItem.optionSetId) { + dimension.optionSet = metadataItem.optionSetId + } + if (metadataItem.valueType) { + dimension.valueType = metadataItem.valueType + } + if (metadataItem.dimensionItemType) { + dimension.dimensionItemType = metadataItem.dimensionItemType + } + + return dimension +} diff --git a/src/modules/dimension/suffix.ts b/src/modules/dimension/suffix.ts index 1d61891cd..49a9055ae 100644 --- a/src/modules/dimension/suffix.ts +++ b/src/modules/dimension/suffix.ts @@ -1,4 +1,5 @@ -import type { DimensionMetadataItem, MetadataItem } from '@types' +import { resolveLayoutContext } from '@modules/layout' +import type { DimensionMetadataItem, MetadataItem, MetadataStore } from '@types' export type SuffixContext = { programCount: number @@ -87,3 +88,24 @@ export const getDimensionSuffix = ( } return undefined } + +/* Pure counterpart of the layout panel's suffix hook, for callers that read the + * metadata store directly. Throws if a dimension has no metadata. */ +export const getSuffixContext = ( + dimensionIds: string[], + metadataStore: MetadataStore +): SuffixContext => { + const { programIds, programStageIds } = resolveLayoutContext( + dimensionIds, + metadataStore + ) + return buildSuffixContext({ + programs: Object.values(metadataStore.getMetadataItems(programIds)), + programStages: Object.values( + metadataStore.getMetadataItems(programStageIds) + ), + }) +} + +export const getDimensionLabel = (name: string, suffix?: string): string => + suffix ? `${name} · ${suffix}` : name diff --git a/src/modules/dimension/value-texts.ts b/src/modules/dimension/value-texts.ts new file mode 100644 index 000000000..c046f79a3 --- /dev/null +++ b/src/modules/dimension/value-texts.ts @@ -0,0 +1,49 @@ +import type { LayoutDimension } from '@components/layout-panel/axis/chip' +import { + getConditionsMetadataIds, + getConditionsTexts, + type Conditions, + type FormatValueOptions, +} from '@modules/conditions' +import { isItemBasedDimensionType } from '@modules/dimension/dimension-type' +import { + getItemDisplayNames, + getItemMetadataIds, +} from '@modules/dimension/item-names' +import type { MetadataItem } from '@types' + +type DimensionValues = { + dimension: LayoutDimension + itemIds: string[] + conditions: Conditions +} + +export const getDimensionValueMetadataIds = ({ + dimension, + itemIds, + conditions, +}: DimensionValues): string[] => + isItemBasedDimensionType(dimension.dimensionType) + ? getItemMetadataIds(itemIds) + : getConditionsMetadataIds({ conditions, dimension }) + +export const getDimensionValueTexts = ({ + dimension, + itemIds, + conditions, + metadataItems, + formatValueOptions, + formatStartEndDate, +}: DimensionValues & { + metadataItems: Record<string, MetadataItem | undefined> + formatValueOptions: FormatValueOptions + formatStartEndDate: (startEndDate: string) => string +}): string[] => + isItemBasedDimensionType(dimension.dimensionType) + ? getItemDisplayNames({ itemIds, metadataItems, formatStartEndDate }) + : getConditionsTexts({ + conditions, + dimension, + formatValueOptions, + metadataItems, + }) diff --git a/src/modules/metadata/visualization.ts b/src/modules/metadata/visualization.ts index 8d75549ae..57c2c12aa 100644 --- a/src/modules/metadata/visualization.ts +++ b/src/modules/metadata/visualization.ts @@ -72,6 +72,9 @@ export const extractTrackedEntityTypeMetadata = ( [visualization.trackedEntityType.id]: { id: visualization.trackedEntityType.id, name: visualization.trackedEntityType.name, + displayTrackedEntityTypesLabel: + visualization.trackedEntityType + .displayTrackedEntityTypesLabel, }, } : {} diff --git a/src/modules/query.ts b/src/modules/query.ts index 315e3f37e..9a67a62fd 100644 --- a/src/modules/query.ts +++ b/src/modules/query.ts @@ -29,7 +29,9 @@ export const getProgramFields = ( `${displayNameProp}~rename(name)`, 'displayEnrollmentDateLabel', 'displayEnrollmentLabel', + 'displayEnrollmentsLabel', 'displayEventLabel', + 'displayEventsLabel', 'displayIncidentDate', 'displayIncidentDateLabel', 'displayOrgUnitLabel', @@ -38,7 +40,7 @@ export const getProgramFields = ( 'enrollmentDateLabel', 'incidentDateLabel', `programStages[${programStageFields}]`, - `trackedEntityType[id,${displayNameProp}~rename(name)]`, + `trackedEntityType[${getTrackedEntityTypeFields(displayNameProp)}]`, ].join(',') /** @@ -46,7 +48,12 @@ export const getProgramFields = ( */ export const getTrackedEntityTypeFields = ( displayNameProp: CurrentUser['settings']['displayNameProperty'] -): string => ['id', `${displayNameProp}~rename(name)`].join(',') +): string => + [ + 'id', + `${displayNameProp}~rename(name)`, + 'displayTrackedEntityTypesLabel', + ].join(',') /* * Option sets and legend sets have no shortName, so their names always come diff --git a/src/modules/utils/dates.ts b/src/modules/utils/dates.ts index 173d19f46..f9a931a8c 100644 --- a/src/modules/utils/dates.ts +++ b/src/modules/utils/dates.ts @@ -15,25 +15,31 @@ export const isStartEndDate = (id: string): boolean => type UseLocalizedStartEndDateFormatterResult = (startEndDate: string) => string +/* `locale` undefined resolves to the runtime default, which is what the + * dashboard plugin gets: it has no access to the user's settings. */ +export const getStartEndDateFormatter = ( + locale?: string +): UseLocalizedStartEndDateFormatterResult => { + const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'long' }) + + return (startEndDate) => + getStartEndDate(startEndDate) + .map((dateStr: string) => formatter.format(new Date(dateStr))) + .join(' - ') +} + export const useLocalizedStartEndDateFormatter = (): UseLocalizedStartEndDateFormatterResult => { const currentUser = useCurrentUser() + const uiLocale = currentUser.settings.uiLocale const formatter = useMemo( - () => - new Intl.DateTimeFormat(currentUser.settings.uiLocale, { - dateStyle: 'long', - }), - [currentUser] + () => getStartEndDateFormatter(uiLocale), + [uiLocale] ) return useCallback( - (startEndDate) => - getStartEndDate(startEndDate) - .map((dateStr: string) => - formatter.format(new Date(dateStr)) - ) - .join(' - '), + (startEndDate) => formatter(startEndDate), [formatter] ) } diff --git a/src/modules/visualization/__tests__/filter-text.spec.ts b/src/modules/visualization/__tests__/filter-text.spec.ts new file mode 100644 index 000000000..de60fe52d --- /dev/null +++ b/src/modules/visualization/__tests__/filter-text.spec.ts @@ -0,0 +1,319 @@ +import { MetadataStore } from '@modules/metadata/store' +import { getVisualizationFilterText } from '@modules/visualization/filter-text' +import type { CurrentVisualization, MetadataInputMap } from '@types' +import { describe, expect, it } from 'vitest' + +const PROGRAM_ID = 'prg1' +const STAGE_ID = 'stg1' + +const buildStore = (items: MetadataInputMap = {}) => { + const store = new MetadataStore({}) + store.addMetadata({ + [PROGRAM_ID]: { + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + }, + [STAGE_ID]: { + id: STAGE_ID, + name: 'Stage one', + repeatable: false, + hideDueDate: false, + program: { id: PROGRAM_ID }, + }, + ...items, + }) + return store +} + +const buildVis = ( + filters: CurrentVisualization['filters'], + overrides: Partial<CurrentVisualization> = {} +): CurrentVisualization => + ({ + type: 'LINE_LIST', + outputType: 'EVENT', + columns: [], + rows: [], + filters, + ...overrides, + }) as CurrentVisualization + +describe('getVisualizationFilterText', () => { + it('returns an empty string when there are no filters', () => { + expect( + getVisualizationFilterText({ + visualization: buildVis([]), + metadataStore: buildStore(), + }) + ).toBe('') + }) + + it('names an item-based filter and its selected items', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + O6uvpzGd5pu: { id: 'O6uvpzGd5pu', name: 'Bo' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }, { id: 'O6uvpzGd5pu' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone, Bo') + }) + + it('collapses org unit levels into a single entry', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + LEVEL_ID: { id: 'LEVEL_ID', name: 'District' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'LEVEL-LEVEL_ID' }, { id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone, Levels: District') + }) + + it('renders an operator condition as text', () => { + const store = buildStore({ + [`${STAGE_ID}.de1`]: { + id: `${STAGE_ID}.de1`, + dimensionId: 'de1', + name: 'Age', + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + }) + const vis = buildVis([ + { + dimension: 'de1', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + dimensionType: 'DATA_ELEMENT', + valueType: 'INTEGER', + filter: 'GT:20', + items: [], + }, + ] as CurrentVisualization['filters']) + + const text = getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + + expect(text).toContain('Age:') + expect(text).toContain('20') + }) + + it('joins several filters with a dash', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + [`${STAGE_ID}.eventDate`]: { + id: `${STAGE_ID}.eventDate`, + dimensionId: 'eventDate', + name: 'Report date', + dimensionType: 'PERIOD', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + THIS_YEAR: { id: 'THIS_YEAR', name: 'This year' }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + { + dimension: 'eventDate', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'THIS_YEAR' }], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit: Sierra Leone - Report date: This year') + }) + + it('names an enrollment org unit filter', () => { + const store = buildStore({ + [`${PROGRAM_ID}.enrollmentOu`]: { + id: `${PROGRAM_ID}.enrollmentOu`, + dimensionId: 'enrollmentOu', + name: 'Enrollment org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + }) + const vis = buildVis( + [ + { + dimension: 'ou', + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters'], + { outputType: 'ENROLLMENT' } + ) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Enrollment org unit: Sierra Leone') + }) + + it('adds the stage name when the layout spans several stages', () => { + const store = buildStore({ + stg2: { + id: 'stg2', + name: 'Stage two', + repeatable: false, + hideDueDate: false, + program: { id: PROGRAM_ID }, + }, + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + 'stg2.eventDate': { + id: 'stg2.eventDate', + dimensionId: 'eventDate', + name: 'Report date', + dimensionType: 'PERIOD', + programId: PROGRAM_ID, + programStageId: 'stg2', + }, + ImspTQPwCqd: { id: 'ImspTQPwCqd', name: 'Sierra Leone' }, + }) + const vis = buildVis( + [ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'ImspTQPwCqd' }], + }, + ] as CurrentVisualization['filters'], + { + columns: [ + { + dimension: 'eventDate', + programStage: { id: 'stg2' }, + program: { id: PROGRAM_ID }, + }, + ], + } as Partial<CurrentVisualization> + ) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('Org unit · Stage one: Sierra Leone') + }) + + it('skips a filter with neither items nor conditions', () => { + const store = buildStore({ + [`${STAGE_ID}.ou`]: { + id: `${STAGE_ID}.ou`, + dimensionId: 'ou', + name: 'Org unit', + dimensionType: 'ORGANISATION_UNIT', + programId: PROGRAM_ID, + programStageId: STAGE_ID, + }, + }) + const vis = buildVis([ + { + dimension: 'ou', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [], + }, + ] as CurrentVisualization['filters']) + + expect( + getVisualizationFilterText({ + visualization: vis, + metadataStore: store, + }) + ).toBe('') + }) + + it('throws for a filter dimension with no metadata', () => { + const vis = buildVis([ + { + dimension: 'unknown', + programStage: { id: STAGE_ID }, + program: { id: PROGRAM_ID }, + items: [{ id: 'x' }], + }, + ] as CurrentVisualization['filters']) + + expect(() => + getVisualizationFilterText({ + visualization: vis, + metadataStore: buildStore(), + }) + ).toThrow() + }) +}) diff --git a/src/modules/visualization/__tests__/title.spec.ts b/src/modules/visualization/__tests__/title.spec.ts new file mode 100644 index 000000000..3b4656360 --- /dev/null +++ b/src/modules/visualization/__tests__/title.spec.ts @@ -0,0 +1,244 @@ +import { MetadataStore } from '@modules/metadata/store' +import { getVisualizationTitle } from '@modules/visualization/title' +import type { CurrentVisualization, MetadataInputMap } from '@types' +import { describe, expect, it } from 'vitest' + +const PROGRAM_ID = 'prg1' +const TET_ID = 'tet1' + +const buildStore = (items: MetadataInputMap = {}) => { + const store = new MetadataStore({}) + store.addMetadata(items) + return store +} + +const buildProgram = (labels: Record<string, string> = {}) => ({ + id: PROGRAM_ID, + name: 'Programme', + programType: 'WITHOUT_REGISTRATION', + ...labels, +}) + +const buildVis = ( + overrides: Partial<CurrentVisualization> = {} +): CurrentVisualization => + ({ + type: 'LINE_LIST', + outputType: 'EVENT', + columns: [], + rows: [], + filters: [], + programDimensions: [buildProgram()], + ...overrides, + }) as CurrentVisualization + +describe('getVisualizationTitle', () => { + it('returns undefined when the title is hidden', () => { + const vis = buildVis({ hideTitle: true, title: 'Ignored' }) + + expect(getVisualizationTitle(vis, buildStore())).toBeUndefined() + }) + + it('returns the custom title when one is set', () => { + const vis = buildVis({ title: 'My title' }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('My title') + }) + + it('treats a whitespace-only title as absent and generates instead', () => { + const vis = buildVis({ title: ' ' }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + }) + + it('generates the bare plural when the program has no custom labels', () => { + expect(getVisualizationTitle(buildVis(), buildStore())).toBe('Events') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE' }), + buildStore() + ) + ).toBe('Events') + }) + + it('prefers the plural event label for both visualization types', () => { + const store = buildStore() + const programDimensions = [ + buildProgram({ + displayEventLabel: 'visit', + displayEventsLabel: 'Visits', + }), + ] as CurrentVisualization['programDimensions'] + + expect( + getVisualizationTitle(buildVis({ programDimensions }), store) + ).toBe('Visits') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE', programDimensions }), + store + ) + ).toBe('Visits') + }) + + it('falls back to the singular event label with per-type wording', () => { + const store = buildStore() + const programDimensions = [ + buildProgram({ + displayEventLabel: 'Visit', + }), + ] as CurrentVisualization['programDimensions'] + + expect( + getVisualizationTitle(buildVis({ programDimensions }), store) + ).toBe('Visit list') + expect( + getVisualizationTitle( + buildVis({ type: 'PIVOT_TABLE', programDimensions }), + store + ) + ).toBe('Visit count') + }) + + it('uses the enrollment labels for enrollment output', () => { + const store = buildStore() + const programDimensions = [ + buildProgram({ + displayEnrollmentLabel: 'Pregnancy', + }), + ] as CurrentVisualization['programDimensions'] + + expect( + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT', programDimensions }), + store + ) + ).toBe('Pregnancy list') + expect( + getVisualizationTitle( + buildVis({ + outputType: 'ENROLLMENT', + type: 'PIVOT_TABLE', + programDimensions, + }), + store + ) + ).toBe('Pregnancy count') + }) + + it('prefers the plural enrollment label', () => { + const store = buildStore() + const programDimensions = [ + buildProgram({ + displayEnrollmentLabel: 'Pregnancy', + displayEnrollmentsLabel: 'Pregnancies', + }), + ] as CurrentVisualization['programDimensions'] + + expect( + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT', programDimensions }), + store + ) + ).toBe('Pregnancies') + }) + + it('generates Enrollments when the program has no enrollment labels', () => { + expect( + getVisualizationTitle( + buildVis({ outputType: 'ENROLLMENT' }), + buildStore() + ) + ).toBe('Enrollments') + }) + + it('uses the tracked entity type plural label when present', () => { + const store = buildStore({ + [TET_ID]: { + id: TET_ID, + name: 'Person', + displayTrackedEntityTypesLabel: 'Patients', + }, + }) + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: TET_ID, name: 'Person' }, + }) + + expect(getVisualizationTitle(vis, store)).toBe('Patients') + }) + + it('falls back to the tracked entity type name with per-type wording', () => { + const store = buildStore({ [TET_ID]: { id: TET_ID, name: 'Patient' } }) + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: TET_ID, name: 'Patient' }, + }) + + expect(getVisualizationTitle(vis, store)).toBe('Patient list') + expect( + getVisualizationTitle({ ...vis, type: 'PIVOT_TABLE' }, store) + ).toBe('Patient count') + }) + + it('throws when the tracked entity type is unknown to the metadata store', () => { + const vis = buildVis({ + outputType: 'TRACKED_ENTITY_INSTANCE', + trackedEntityType: { id: 'missing', name: 'Person' }, + }) + + expect(() => getVisualizationTitle(vis, buildStore())).toThrow() + }) + + it('replaces the output-type title with the custom value on a pivot', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ + type: 'PIVOT_TABLE', + value: { id: 'de1' }, + aggregationType: 'AVERAGE', + }) + + expect(getVisualizationTitle(vis, store)).toBe( + 'MCH Apgar score · Average' + ) + }) + + it('ignores a custom value that is missing its aggregation type', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ type: 'PIVOT_TABLE', value: { id: 'de1' } }) + + expect(getVisualizationTitle(vis, store)).toBe('Events') + }) + + it('throws when the custom value item is unknown to the metadata store', () => { + const vis = buildVis({ + type: 'PIVOT_TABLE', + value: { id: 'missing' }, + aggregationType: 'AVERAGE', + }) + + expect(() => getVisualizationTitle(vis, buildStore())).toThrow() + }) + + it('ignores a custom value on a line list', () => { + const store = buildStore({ + de1: { id: 'de1', name: 'MCH Apgar score' }, + }) + const vis = buildVis({ + value: { id: 'de1' }, + aggregationType: 'AVERAGE', + }) + + expect(getVisualizationTitle(vis, store)).toBe('Events') + }) + + it('falls back to the bare plural when there is no program', () => { + const vis = buildVis({ programDimensions: [] }) + + expect(getVisualizationTitle(vis, buildStore())).toBe('Events') + }) +}) diff --git a/src/modules/visualization/__tests__/unapplied-changes.spec.ts b/src/modules/visualization/__tests__/unapplied-changes.spec.ts index 1908fbeaf..84b96215d 100644 --- a/src/modules/visualization/__tests__/unapplied-changes.spec.ts +++ b/src/modules/visualization/__tests__/unapplied-changes.spec.ts @@ -39,6 +39,7 @@ const baseCurrentVis = { columns: [], rows: [], filters: [], + hideSubtitle: true, } as unknown as CurrentVisualization /* Compose the two functions exactly as useHasUnappliedChanges does, so these diff --git a/src/modules/visualization/__tests__/visualization.spec.ts b/src/modules/visualization/__tests__/visualization.spec.ts index 116400fb4..6a7deb3f3 100644 --- a/src/modules/visualization/__tests__/visualization.spec.ts +++ b/src/modules/visualization/__tests__/visualization.spec.ts @@ -661,6 +661,75 @@ describe('normalizeApiSavedVisualization', () => { expect(result.legacy).toBe(true) }) + it('maps a legacy visualization with no title to a hidden title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideTitle: false }) + ) + + expect(result.hideTitle).toBe(true) + }) + + it('maps a legacy visualization with a title to a shown title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideTitle: true, title: 'Kept' }) + ) + + expect(result.hideTitle).toBe(false) + expect(result.title).toBe('Kept') + }) + + it('treats a legacy whitespace-only title as no title', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, title: ' ' }) + ) + + expect(result.hideTitle).toBe(true) + }) + + it('hides the subtitle of a legacy visualization that has none', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideSubtitle: false }) + ) + + expect(result.hideSubtitle).toBe(true) + }) + + it('shows the subtitle of a legacy visualization that has one', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ legacy: true, hideSubtitle: true, subtitle: 'Kept' }) + ) + + expect(result.hideSubtitle).toBe(false) + }) + + it('honours the stored hideSubtitle on a non-legacy visualization', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ hideSubtitle: false }) + ) + + expect(result.hideSubtitle).toBe(false) + }) + + it('honours the stored hideTitle on a non-legacy visualization', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ hideTitle: false }) + ) + + expect(result.hideTitle).toBe(false) + }) + + it('hides the title on a visualization made legacy by an upgrade', () => { + const result = normalizeApiSavedVisualization( + buildApiVis({ + hideTitle: false, + program: { id: PID }, + } as Partial<ApiSavedVisualization>) + ) + + expect(result.legacy).toBe(true) + expect(result.hideTitle).toBe(true) + }) + it('leaves ENROLLMENT enrollment `ou` as `ou`', () => { const result = normalizeApiSavedVisualization( buildApiVis({ @@ -764,6 +833,7 @@ describe('getVisualizationState treats default-valued options as unchanged', () const apiDefaultOptions: Partial<EventVisualizationOptions> = { ...DEFAULT_OPTIONS, digitGroupSeparator: 'SPACE', + hideSubtitle: true, } const buildApiVis = ( @@ -833,6 +903,20 @@ describe('getVisualizationState treats default-valued options as unchanged', () expect(getVisualizationState(savedVis, currentVis)).toBe('SAVED') }) + it('hides an empty subtitle when rebuilding', () => { + const savedVis = normalizeApiSavedVisualization(buildApiVis()) + + expect(rebuildCurrentVis(savedVis).hideSubtitle).toBe(true) + }) + + it('shows a populated subtitle when rebuilding', () => { + const savedVis = normalizeApiSavedVisualization( + buildApiVis({ ...apiDefaultOptions, subtitle: 'Shown' }) + ) + + expect(rebuildCurrentVis(savedVis).hideSubtitle).toBe(false) + }) + it('is DIRTY when a real option changes', () => { const savedVis = normalizeApiSavedVisualization(buildApiVis()) diff --git a/src/modules/visualization/current-vis.ts b/src/modules/visualization/current-vis.ts index 5b40af09a..7d3f0c4cb 100644 --- a/src/modules/visualization/current-vis.ts +++ b/src/modules/visualization/current-vis.ts @@ -5,6 +5,7 @@ import { resolveTeiFields, } from '@modules/layout' import { getEnabledOptions } from '@modules/options' +import { isPopulatedString } from '@modules/utils/guards' import type { CurrentVisState } from '@store/current-vis-slice' import type { VisUiConfigState } from '@store/vis-ui-config-slice' import type { @@ -132,6 +133,8 @@ export const buildCurrentVisFromVisUiConfig = ({ filters: buildAxis(visUiConfig.layout.filters, visUiConfig, metadataStore), programDimensions: collectProgramDimensions(visUiConfig, metadataStore), ...getEnabledOptions(visUiConfig.options), + // There is no generated subtitle, so an empty one is a hidden one + hideSubtitle: !isPopulatedString(visUiConfig.options.subtitle), ...resolveTeiFields(visUiConfig, metadataStore), ...resolveCustomValueFields( previousCurrentVis, diff --git a/src/modules/visualization/filter-text.ts b/src/modules/visualization/filter-text.ts new file mode 100644 index 000000000..d504bdc2c --- /dev/null +++ b/src/modules/visualization/filter-text.ts @@ -0,0 +1,72 @@ +import { toLayoutDimension } from '@modules/dimension/layout-dimension' +import { getDimensionLabel, getSuffixContext } from '@modules/dimension/suffix' +import { + getDimensionValueMetadataIds, + getDimensionValueTexts, +} from '@modules/dimension/value-texts' +import { getStartEndDateFormatter } from '@modules/utils/dates' +import { getVisualizationUiConfig } from '@modules/visualization/ui-config' +import type { CurrentVisualization, MetadataStore } from '@types' + +// Aligned with the separator the pivot table engine produces +const FRAGMENT_SEPARATOR = ' - ' + +/** + * The text of the filter line shown beneath the title and subtitle, for both + * the line list and the pivot table. + * + * `locale` is only used to format custom start/end dates, and falls back to + * the runtime default when the user's locale has not resolved yet. + */ +export const getVisualizationFilterText = ({ + visualization, + metadataStore, + locale, +}: { + visualization: CurrentVisualization + metadataStore: MetadataStore + locale?: string +}): string => { + const { layout, itemsByDimension, conditionsByDimension } = + getVisualizationUiConfig(visualization) + if (!layout.filters.length) { + return '' + } + + const suffixContext = getSuffixContext( + [...layout.columns, ...layout.rows, ...layout.filters], + metadataStore + ) + const formatStartEndDate = getStartEndDateFormatter(locale) + + return layout.filters + .flatMap((dimensionId) => { + const dimension = toLayoutDimension( + dimensionId, + metadataStore.getDimensionMetadataItemOrThrow(dimensionId), + suffixContext + ) + const values = { + dimension, + itemIds: itemsByDimension[dimensionId] ?? [], + conditions: conditionsByDimension[dimensionId] ?? {}, + } + const valueTexts = getDimensionValueTexts({ + ...values, + metadataItems: metadataStore.getMetadataItems( + getDimensionValueMetadataIds(values) + ), + formatValueOptions: { + digitGroupSeparator: visualization.digitGroupSeparator, + }, + formatStartEndDate, + }) + + return valueTexts.length + ? [ + `${getDimensionLabel(dimension.name, dimension.suffix)}: ${valueTexts.join(', ')}`, + ] + : [] + }) + .join(FRAGMENT_SEPARATOR) +} diff --git a/src/modules/visualization/normalize-legacy.ts b/src/modules/visualization/normalize-legacy.ts index 5209de54d..0530a18c9 100644 --- a/src/modules/visualization/normalize-legacy.ts +++ b/src/modules/visualization/normalize-legacy.ts @@ -11,6 +11,7 @@ import { timeFieldTimeDimensionMap, } from '@modules/dimension/time' import { toEventVisualizationDimensionId } from '@modules/dimension/translation' +import { isPopulatedString } from '@modules/utils/guards' import type { ApiSavedVisualization, DimensionId, @@ -305,5 +306,13 @@ export const normalizeApiSavedVisualization = ( normalizedVis.topLimit = topLimit } + /* Legacy visualizations don't set the hide flags reliably, so they are + * inferred from the text. The apps that produced them never generated a + * title, so an empty title means "no title" rather than "auto generated". */ + if (normalizedVis.legacy) { + normalizedVis.hideTitle = !isPopulatedString(normalizedVis.title) + normalizedVis.hideSubtitle = !isPopulatedString(normalizedVis.subtitle) + } + return normalizedVis } diff --git a/src/modules/visualization/title.ts b/src/modules/visualization/title.ts new file mode 100644 index 000000000..11b645177 --- /dev/null +++ b/src/modules/visualization/title.ts @@ -0,0 +1,107 @@ +import { aggregationTypeDisplayNames } from '@constants/aggregation-types' +import i18n from '@dhis2/d2-i18n' +import { isPopulatedString } from '@modules/utils/guards' +import type { CurrentVisualization, MetadataStore } from '@types' + +const getCountOrListTitle = ( + label: string, + visualization: CurrentVisualization +): string => + visualization.type === 'PIVOT_TABLE' + ? i18n.t('{{- label}} count', { label }) + : i18n.t('{{- label}} list', { label }) + +type VisualizationWithCustomValue = CurrentVisualization & { + value: { id: string } + aggregationType: NonNullable<CurrentVisualization['aggregationType']> +} + +const hasCustomValue = ( + visualization: CurrentVisualization +): visualization is VisualizationWithCustomValue => + visualization.type === 'PIVOT_TABLE' && + Boolean(visualization.value?.id) && + Boolean(visualization.aggregationType) + +const getCustomValueTitle = ( + visualization: VisualizationWithCustomValue, + metadataStore: MetadataStore +): string => { + const itemName = metadataStore.getMetadataItemOrThrow( + visualization.value.id + ).name + const aggregationName = + aggregationTypeDisplayNames[visualization.aggregationType] + + return `${itemName} · ${aggregationName}` +} + +const getTrackedEntityTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string => { + const tetId = visualization.trackedEntityType?.id + if (!tetId) { + throw new Error( + 'A tracked entity visualization has no tracked entity type' + ) + } + const tet = metadataStore.getMetadataItemOrThrow(tetId) + const pluralLabel = + 'displayTrackedEntityTypesLabel' in tet + ? tet.displayTrackedEntityTypesLabel + : undefined + + return isPopulatedString(pluralLabel) + ? pluralLabel + : getCountOrListTitle(tet.name, visualization) +} + +export const getAutoTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string => { + if (hasCustomValue(visualization)) { + return getCustomValueTitle(visualization, metadataStore) + } + + if (visualization.outputType === 'TRACKED_ENTITY_INSTANCE') { + return getTrackedEntityTitle(visualization, metadataStore) + } + + const program = visualization.programDimensions?.[0] + + if (visualization.outputType === 'ENROLLMENT') { + if (isPopulatedString(program?.displayEnrollmentsLabel)) { + return program.displayEnrollmentsLabel + } + if (isPopulatedString(program?.displayEnrollmentLabel)) { + return getCountOrListTitle( + program.displayEnrollmentLabel, + visualization + ) + } + return i18n.t('Enrollments') + } + + if (isPopulatedString(program?.displayEventsLabel)) { + return program.displayEventsLabel + } + if (isPopulatedString(program?.displayEventLabel)) { + return getCountOrListTitle(program.displayEventLabel, visualization) + } + return i18n.t('Events') +} + +export const getVisualizationTitle = ( + visualization: CurrentVisualization, + metadataStore: MetadataStore +): string | undefined => { + if (visualization.hideTitle) { + return undefined + } + if (isPopulatedString(visualization.title)) { + return visualization.title + } + return getAutoTitle(visualization, metadataStore) +} diff --git a/src/store/__tests__/listeners.spec.ts b/src/store/__tests__/listeners.spec.ts index 9b659a159..0f4641e15 100644 --- a/src/store/__tests__/listeners.spec.ts +++ b/src/store/__tests__/listeners.spec.ts @@ -7,6 +7,8 @@ import { navigationSlice, setNavigationState } from '../navigation-slice' import type { NavigationState } from '../navigation-slice' import { tClearVisualization, tLoadSavedVisualization } from '../thunks' import { + setVisUiConfigOption, + setVisUiConfigOutputType, setVisUiConfigVisualizationType, visUiConfigSlice, } from '../vis-ui-config-slice' @@ -82,17 +84,20 @@ describe('navigationSlice listener', () => { }) describe('visUiConfig visualization type listener', () => { + const createVisUiConfigStore = () => + configureStore({ + reducer: { visUiConfig: visUiConfigSlice.reducer }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().prepend(listenerMiddleware.middleware), + }) + beforeEach(() => { listenerMiddleware.clearListeners() registerAppListeners() }) it('stores the chosen visualization type as the last used one', async () => { - const store = configureStore({ - reducer: { visUiConfig: visUiConfigSlice.reducer }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().prepend(listenerMiddleware.middleware), - }) + const store = createVisUiConfigStore() store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) await Promise.resolve() @@ -101,4 +106,35 @@ describe('visUiConfig visualization type listener', () => { 'PIVOT_TABLE' ) }) + + it('resets the title to auto generated when the visualization type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'title', value: 'Mine' })) + + store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.title).toBe('') + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('clears a hidden title when the visualization type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'hideTitle', value: true })) + + store.dispatch(setVisUiConfigVisualizationType('PIVOT_TABLE')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.hideTitle).toBe(false) + }) + + it('leaves the title alone when the output type changes', async () => { + const store = createVisUiConfigStore() + store.dispatch(setVisUiConfigOption({ key: 'title', value: 'Mine' })) + + store.dispatch(setVisUiConfigOutputType('ENROLLMENT')) + await Promise.resolve() + + expect(store.getState().visUiConfig.options.title).toBe('Mine') + }) }) diff --git a/src/store/listeners.ts b/src/store/listeners.ts index 26569743b..0ec2bc7c8 100644 --- a/src/store/listeners.ts +++ b/src/store/listeners.ts @@ -11,13 +11,14 @@ import { setUiActiveDimensionModal } from './ui-slice' import { addVisUiConfigLayoutDimension, addVisUiConfigLayoutDimensions, + setVisUiConfigOption, setVisUiConfigVisualizationType, } from './vis-ui-config-slice' export const registerAppListeners = () => { startAppListening({ actionCreator: setNavigationState, - effect: async (action, { dispatch, getOriginalState }) => { + effect: (action, { dispatch, getOriginalState }) => { const originalState = getOriginalState() const originalVisualizationId = originalState.navigation.visualizationId @@ -46,8 +47,12 @@ export const registerAppListeners = () => { startAppListening({ actionCreator: setVisUiConfigVisualizationType, - effect: (action) => { + effect: (action, { dispatch }) => { setLastUsedVisualizationTypeToLocalStorage(action.payload) + + // Changing visualization type resets the title configuration to Auto generated + dispatch(setVisUiConfigOption({ key: 'title', value: '' })) + dispatch(setVisUiConfigOption({ key: 'hideTitle', value: false })) }, }) diff --git a/src/types/analytics/pivot-table.ts b/src/types/analytics/pivot-table.ts index b2baf42df..822d9d57f 100644 --- a/src/types/analytics/pivot-table.ts +++ b/src/types/analytics/pivot-table.ts @@ -5,6 +5,9 @@ import type { FC } from 'react' type PivotTableProps = { data: PivotTableAnalyticsData visualization: CurrentVisualization + /* Rendered as the filter line in place of the text the engine would + * derive from `visualization.filters`. */ + filterText?: string legendSets?: LegendSet[] renderCounter?: number onToggleContextualMenu?: () => void diff --git a/src/types/data-source.ts b/src/types/data-source.ts index f4f2465b4..22dffe211 100644 --- a/src/types/data-source.ts +++ b/src/types/data-source.ts @@ -1,7 +1,7 @@ import type { ProgramType } from './dhis2-openapi-schemas' import type { DimensionType } from './dimension' import type { MetadataItem } from './metadata' -import type { Program } from './program' +import type { Program, TrackedEntityTypeRef } from './program' export type DataSourceProgramWithRegistration = Omit< Program, @@ -9,10 +9,7 @@ export type DataSourceProgramWithRegistration = Omit< > & { programType: Extract<ProgramType, 'WITH_REGISTRATION'> // Required field for tracker program - trackedEntityType: { - id: string - name: string - } + trackedEntityType: TrackedEntityTypeRef } export type DataSourceProgramWithoutRegistration = Omit< Program, diff --git a/src/types/metadata.ts b/src/types/metadata.ts index f7ba3a6ee..c39971e2a 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -4,7 +4,7 @@ import type { LegendSet, } from './dhis2-openapi-schemas' import type { DimensionType } from './dimension' -import type { Program, ProgramStage } from '.' +import type { Program, ProgramStage, TrackedEntityTypeRef } from '.' /** PHASES * 1. Data is provided as a single object, object map, or object array @@ -129,6 +129,7 @@ export type MetadataItem = | UserOrgUnitMetadataItem | Program | ProgramStage + | TrackedEntityTypeRef export type MetadataMap = Map<string, MetadataItem> export type Subscriber = () => void diff --git a/src/types/program.ts b/src/types/program.ts index 623246bc6..d05de4ccf 100644 --- a/src/types/program.ts +++ b/src/types/program.ts @@ -11,7 +11,9 @@ export type Program = Pick< | 'displayIncidentDate' | 'displayEnrollmentDateLabel' | 'displayEnrollmentLabel' + | 'displayEnrollmentsLabel' | 'displayEventLabel' + | 'displayEventsLabel' | 'displayIncidentDateLabel' | 'displayOrgUnitLabel' | 'displayProgramStageLabel' @@ -23,8 +25,11 @@ export type Program = Pick< id: string name: string programStages?: ProgramStage[] - trackedEntityType?: { - id: string - name: string - } + trackedEntityType?: TrackedEntityTypeRef +} + +export type TrackedEntityTypeRef = { + id: string + name: string + displayTrackedEntityTypesLabel?: string } diff --git a/src/types/visualization.ts b/src/types/visualization.ts index 26c82a662..529349ebf 100644 --- a/src/types/visualization.ts +++ b/src/types/visualization.ts @@ -8,7 +8,7 @@ import type { import type { DimensionType } from './dimension' import type { MetadataInputMap } from './metadata' import type { EventVisualizationOptions } from './options' -import type { Program } from './program' +import type { Program, TrackedEntityTypeRef } from './program' import type { ProgramStage } from './program-stage' import type { VisualizationType } from './visualization-type' @@ -71,7 +71,7 @@ type SavedVisualizationFieldOverrides = { program: Program programStage: ProgramStage programDimensions?: ProgramDimensionArray - trackedEntityType?: IdNameRecord + trackedEntityType?: TrackedEntityTypeRef // Custom dimension metadata arrays (from getDimensionMetadataFields) dataElementDimensions: DataElementDimensionArray