Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 32 additions & 31 deletions cypress/integration/dataTable.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ describe('data table', () => {
.findByDataTest('dhis2-uicore-datatablecellhead')
.should('have.length', 10)

// Filter by name
cy.getByDataTest('data-table-column-filter-search-Name')
// Filter by Org unit
cy.getByDataTest('data-table-column-filter-search-Org unit')
.find('input')
.type('bar{enter}')

Expand All @@ -97,20 +97,21 @@ describe('data table', () => {
.should('have.length', 7)

// Confirm that the sort order is initially ascending by Name
checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' })
checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' })
checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' })
checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' })

// Sort by name
cy.getByDataTest('data-table-column-sort-button-Name').click()
// Sort by name (descending)
cy.getByDataTest('data-table-column-sort-button-Org unit').click()
cy.getByDataTest('data-table-column-sort-button-Org unit').click()

// Sorting can shift the virtualized table's scroll position
// (possibly an internal react-virtuoso quirk)
// so we reset to top before asserting on row indices below
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

// Confirm that the rows are sorted by Name descending
checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' })
checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' })
checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' })
checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' })

// Filter by Value (numeric)
cy.getByDataTest('data-table-column-filter-search-Value')
Expand All @@ -130,8 +131,8 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

// Check that the rows are sorted by Value ascending
checkTableCell({ row: 0, column: 3, expectedContent: '35' })
checkTableCell({ row: 4, column: 3, expectedContent: '76' })
checkTableCell({ row: 0, column: 5, expectedContent: '35' })
checkTableCell({ row: 4, column: 5, expectedContent: '76' })

// Right-click a row and select "View profile"
cy.getByDataTest('bottom-panel')
Expand Down Expand Up @@ -161,7 +162,7 @@ describe('data table', () => {
assertMapPosition(expectedBottoms1, expectedHeights1)
})

it('opens the data table for an Event layer', () => {
it('opens data table for an Event layer', () => {
cy.visit('/')

const EvenLayer = new EventLayer()
Expand Down Expand Up @@ -195,7 +196,7 @@ describe('data table', () => {
// Check number of columns
cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-datatablecellhead')
.should('have.length', 10)
.should('have.length', 13)

cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-datatablecellhead')
Expand All @@ -209,8 +210,8 @@ describe('data table', () => {
.type(`${ouName}{enter}`)

// Check that all the rows have Org unit Moyowa
checkTableCell({ row: 0, column: 1, expectedContent: ouName })
checkTableCell({ row: 2, column: 1, expectedContent: ouName })
checkTableCell({ row: 0, column: 3, expectedContent: ouName })
checkTableCell({ row: 2, column: 3, expectedContent: ouName })

cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-tablebody')
Expand Down Expand Up @@ -255,8 +256,8 @@ describe('data table', () => {

// Confirm that the rows are sorted by Age in years ascending
// (the first click on a new column always sorts ascending)
checkTableCell({ row: 0, column: 7, expectedContent: '6' })
checkTableCell({ row: 1, column: 7, expectedContent: '32' })
checkTableCell({ row: 0, column: 10, expectedContent: '6' })
checkTableCell({ row: 1, column: 10, expectedContent: '32' })

// Right-click a row: Event layers have no profile to view
cy.getByDataTest('bottom-panel')
Expand Down Expand Up @@ -317,7 +318,7 @@ describe('data table', () => {
cy.getByDataTest('layers-toggle-button').click()

// Confirm that the sort order is initially ascending by Name
checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' })
checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' })

// First click on a new column always sorts ascending
cy.getByDataTest('data-table-column-sort-button-Value').click()
Expand All @@ -326,29 +327,29 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

// Check that first row has Tihun CHC with value 28.63
checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' })
checkTableCell({ row: 0, column: 3, expectedContent: '28.63' })
checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' })
checkTableCell({ row: 0, column: 5, expectedContent: '28.63' })

// Check that row 5 has Gbamgbama CHC with value 117.98
checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' })
checkTableCell({ row: 5, column: 3, expectedContent: '117.98' })
checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' })
checkTableCell({ row: 5, column: 5, expectedContent: '117.98' })

// Check that row 6 has no value (undefined)
checkTableCell({ row: 6, column: 3, expectedContent: '' })
checkTableCell({ row: 6, column: 5, expectedContent: '' })

// Sort descending by Value
cy.getByDataTest('data-table-column-sort-button-Value').click()

// Reset scroll position after sorting - see comment above
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' })
checkTableCell({ row: 0, column: 3, expectedContent: '117.98' })
checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' })
checkTableCell({ row: 0, column: 5, expectedContent: '117.98' })

checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' })
checkTableCell({ row: 5, column: 3, expectedContent: '28.63' })
checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' })
checkTableCell({ row: 5, column: 5, expectedContent: '28.63' })

checkTableCell({ row: 6, column: 3, expectedContent: '' })
checkTableCell({ row: 6, column: 5, expectedContent: '' })

// Third click on the same column cycles back to natural (unsorted)
// order - there's no dedicated Index column/button any more
Expand All @@ -358,7 +359,7 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

// Check that row 0 range value is empty
checkTableCell({ row: 0, column: 8, expectedContent: '' })
checkTableCell({ row: 0, column: 7, expectedContent: '' })

// Sort by range, which is a string
cy.getByDataTest('data-table-column-sort-button-Range').click()
Expand All @@ -367,12 +368,12 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')

// Check that row 0 range value has value '0-40'
checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' })
checkTableCell({ row: 0, column: 7, expectedContent: '0 – 40' })

// Check that row 5 range value has value '90 - 120'
checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' })
checkTableCell({ row: 5, column: 7, expectedContent: '90 – 120' })

// Check that row 6 range value is empty
checkTableCell({ row: 6, column: 8, expectedContent: '' })
checkTableCell({ row: 6, column: 7, expectedContent: '' })
})
})
59 changes: 49 additions & 10 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2026-09-07T08:34:02.249Z\n"
"PO-Revision-Date: 2026-09-07T08:34:02.249Z\n"
"POT-Creation-Date: 2026-09-07T10:22:12.476Z\n"
"PO-Revision-Date: 2026-09-07T10:22:12.476Z\n"

msgid "2020"
msgstr "2020"
Expand Down Expand Up @@ -170,6 +170,15 @@ msgstr "Sort by {{column}}"
msgid "Edit layer"
msgstr "Edit layer"

msgid "Select a year, month, day or hour"
msgstr "Select a year, month, day or hour"

msgid "to match the events under it, or type to search"
msgstr "to match the events under it, or type to search"

msgid "Contains"
msgstr "Contains"

msgid "Something went wrong"
msgstr "Something went wrong"

Expand Down Expand Up @@ -197,9 +206,6 @@ msgstr "to match rows that contain it"
msgid "Use filter"
msgstr "Use filter"

msgid "Contains"
msgstr "Contains"

msgid "Search or type > 5, < 8…"
msgstr "Search or type > 5, < 8…"

Expand All @@ -221,6 +227,21 @@ msgstr "No matches"
msgid "No value"
msgstr "No value"

msgid "Collapse {{label}}"
msgstr "Collapse {{label}}"

msgid "Expand {{label}}"
msgstr "Expand {{label}}"

msgid "Select a country, region, district or facility"
msgstr "Select a country, region, district or facility"

msgid "to match the rows under it, or type to search"
msgstr "to match the rows under it, or type to search"

msgid "Select matches"
msgstr "Select matches"

msgid "Selected"
msgstr "Selected"

Expand Down Expand Up @@ -2059,18 +2080,30 @@ msgstr "GroupSet used for styling was not found"
msgid "Id"
msgstr "Id"

msgid "Type"
msgstr "Type"

msgid "Range"
msgstr "Range"
msgid "Org unit Id"
msgstr "Org unit Id"

msgid "Org unit"
msgstr "Org unit"

msgid "Org unit level"
msgstr "Org unit level"

msgid "Geometry type"
msgstr "Geometry type"

msgid "Range"
msgstr "Range"

msgid "Org unit boundary"
msgstr "Org unit boundary"

msgid "Org unit hierarchy"
msgstr "Org unit hierarchy"

msgid "Created"
msgstr "Created"

msgid "Group"
msgstr "Group"

Expand All @@ -2083,6 +2116,12 @@ msgstr "Current period"
msgid "Value ({{period}})"
msgstr "Value ({{period}})"

msgid "Event Id"
msgstr "Event Id"

msgid "Tracked entity Id"
msgstr "Tracked entity Id"

msgid "Start date is invalid"
msgstr "Start date is invalid"

Expand Down
5 changes: 5 additions & 0 deletions src/components/datatable/DataTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const Table = ({
totalCount,
filteredCount,
columnOptions,
orgUnitIdToName,
} = useTableData({
layer,
sortField,
Expand Down Expand Up @@ -467,6 +468,7 @@ const Table = ({
options={columnOptions[dataKey]}
optionSetId={optionSet?.id}
renderer={renderer}
orgUnitIdToName={orgUnitIdToName}
/>
)
}
Expand Down Expand Up @@ -528,6 +530,7 @@ const Table = ({
isAllSelected,
onToggleSelectAll,
headerRowRef,
orgUnitIdToName,
]
)

Expand All @@ -551,6 +554,7 @@ const Table = ({
rendererByDataKey={rendererByDataKey}
typeByDataKey={typeByDataKey}
keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator}
orgUnitIdToName={orgUnitIdToName}
onToggleSelection={onToggleSelection}
/>
),
Expand All @@ -566,6 +570,7 @@ const Table = ({
rendererByDataKey,
typeByDataKey,
keyAnalysisDigitGroupSeparator,
orgUnitIdToName,
onToggleSelection,
]
)
Expand Down
86 changes: 86 additions & 0 deletions src/components/datatable/DateGroupFilterInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import i18n from '@dhis2/d2-i18n'
import PropTypes from 'prop-types'
import React, { useCallback } from 'react'
import { setDataFilter } from '../../actions/dataFilters.js'
import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js'
import {
buildDateGroupTree,
formatNodeLabel,
getSearchMatches,
} from '../../util/dateGroups.js'
import { isDateGroupFilter } from '../../util/filter.js'
import GroupFilterPopover from './GroupFilterPopover.jsx'
import useGroupFilterInput from './useGroupFilterInput.js'

const HELP_CONTENT = (
<div>
<div>{i18n.t('Select a year, month, day or hour')}</div>
<div>{i18n.t('to match the events under it, or type to search')}</div>
</div>
)
const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g

const parseFilterValue = (filterValue) => ({
selectedPrefixes: isDateGroupFilter(filterValue)
? filterValue.prefixes
: [],
appliedString: typeof filterValue === 'string' ? filterValue : '',
})

const sanitizeInput = (value) => value.replace(DATE_INPUT_DISALLOWED, '')

const commitSearch = (text, { dispatch, layerId, dataKey }) =>
dispatch(setDataFilter(layerId, dataKey, text))

const DateGroupFilterInput = ({
dataKey,
name,
layerId,
filterValue,
options,
type,
}) => {
const buildTree = useCallback(
(realValues) => buildDateGroupTree(realValues, type),
[type]
)

const groupFilter = useGroupFilterInput({
dataKey,
layerId,
filterValue,
options,
granularity: DATE_GROUPS_GRANULARITY,
buildTree,
getMatches: getSearchMatches,
parseFilterValue,
commitSearch,
sanitizeInput,
})

return (
<GroupFilterPopover
{...groupFilter}
name={name}
helpContent={HELP_CONTENT}
customFilterTag={i18n.t('Contains')}
formatLabel={(node) => formatNodeLabel(node, i18n.language)}
/>
)
}

DateGroupFilterInput.propTypes = {
dataKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string }))
.isRequired,
type: PropTypes.string.isRequired,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
PropTypes.object,
]),
layerId: PropTypes.string,
}

export default DateGroupFilterInput
Loading
Loading