From 4462273820000110f02b3ac4967300ec97a40adf Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 01/47] feat: add bidirectional map/table selection sync and collapsible data table --- src/components/core/icons.jsx | 53 +++++++ .../datatable/__tests__/useTableData.spec.jsx | 138 ++++++++++++++++++ .../datatable/styles/BottomPanel.module.css | 25 ++++ 3 files changed, 216 insertions(+) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 1a067ea7bd..b3a970b952 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,6 +49,59 @@ export const IconZoomIn16 = () => ( ) +// Two stacked chevrons — "collapse"/"restore to full height" toggle. +export const IconChevronDoubleDown16 = () => ( + + + + +) + +export const IconChevronDoubleUp16 = () => ( + + + + +) + export const IconDrag = () => ( { expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9ab8b8aaf2..9041ba8821 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -39,3 +39,28 @@ background-color: var(--colors-grey300); flex-shrink: 0; } + +.toggleButton.active { + color: var(--colors-blue700); + background-color: var(--colors-blue100); +} + +.toggleButton.active:hover { + background-color: var(--colors-blue200); +} + +.highlightColorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; + position: relative; + top: -1px; +} + +.highlightColorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 18px !important; + min-height: 18px !important; +} From 9d8097cd697f9d20796e1033ce83f52b88bf9cc5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 23:34:31 +0200 Subject: [PATCH 02/47] feat: add global search box to data table toolbar Adds a dense Input between the "Clear filters" button and the show-only toggles, sized to shrink before the layer name has to truncate further. "Clear filters" now also resets the search box, and hasActiveFilters accounts for both column filters and the search string. --- src/components/datatable/styles/BottomPanel.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 9041ba8821..8d7a23f0e3 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -64,3 +64,9 @@ min-width: 18px !important; min-height: 18px !important; } + +.globalSearch { + flex: 0 1 160px; + min-width: 90px; + margin-bottom: 0 !important; +} From b1fded3fe4e01d718f7a245e6d51a098eedbf461 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 14 Jul 2026 10:51:51 +0200 Subject: [PATCH 03/47] fix: toolbar polish - clear filters button, search sizing, collapse icon/bug --- src/components/core/icons.jsx | 53 ------------------- .../datatable/styles/BottomPanel.module.css | 20 ++++++- 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b3a970b952..1a067ea7bd 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,59 +49,6 @@ export const IconZoomIn16 = () => ( ) -// Two stacked chevrons — "collapse"/"restore to full height" toggle. -export const IconChevronDoubleDown16 = () => ( - - - - -) - -export const IconChevronDoubleUp16 = () => ( - - - - -) - export const IconDrag = () => ( :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; } From 3c23163340850c1bd79ee055d9acebfdeda66660 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 04/47] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- .../datatable/__tests__/useTableData.spec.jsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 7f0e143c3b..17c0ae892a 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -1494,7 +1494,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -1514,23 +1514,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -1539,12 +1539,37 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], selectedIdSet: new Set(), }) expect(current.rows).toHaveLength(0) From 7f0c55c5ebc1fdb693d489aebd8c38b4cd3e3fa6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 13:35:05 +0200 Subject: [PATCH 05/47] chore: PR clean-up --- src/components/datatable/styles/BottomPanel.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 8573b64a04..083bac7ce9 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -59,6 +59,7 @@ background-color: var(--colors-blue200); } +/* !important beats @dhis2/ui's own ColorPicker field margin. */ .highlightColorPicker { margin-bottom: 0 !important; flex-shrink: 0; @@ -68,6 +69,7 @@ top: -1px; } +/* !important beats @dhis2/ui's own ColorPicker label size. */ .highlightColorPicker label { box-sizing: border-box; overflow: hidden; From b2d35c9dd3034c4c915da406a2dd64267c12d409 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 16:43:54 +0200 Subject: [PATCH 06/47] fix: ColumnPicker permanently disabled for layer types with synchronous headers React fires child effects before parent effects within the same commit. DataTable's header-reporting effect (child) and BottomPanel's own "reset allHeaders on layer switch" effect (parent) both fired on mount/layer-switch; the parent's reset always ran second in that commit, clobbering the real headers DataTable had just reported. Layer types with a synchronous header computation (Thematic/OrgUnit/Facility) never got a second chance to set it, leaving the picker's trigger button permanently disabled - Event layers only worked because their extended-events loading triggers a second, later header recompute that escapes the race. Fix: track headers keyed by which layer produced them (headersByLayer = {layerId, headers}, tagged by DataTable's own effect closure, not a "latest activeLayerId" guess) and derive staleness at render time via a plain comparison, instead of a second effect racing to clear the same state. --- src/components/datatable/DataTable.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 5b4eb8f5aa..729053a30b 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -180,8 +180,8 @@ const Table = ({ }) useEffect(() => { - onHeadersChange?.(headers, activeLayerId) - }, [onHeadersChange, headers, activeLayerId]) + onHeadersChange?.(headers, layer.id) + }, [onHeadersChange, headers, layer.id]) const columnConfig = layer.dataTableColumnConfig const pinnedKeys = useMemo( From 611477821579649bac81c2c5526f4c636f189664 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 17 Jul 2026 22:14:41 +0200 Subject: [PATCH 07/47] feat: support tracked entity layers in the data table Fetches tracked entity attribute values via the tracker API and exposes them as data table columns, following the same headers/dataKey pattern event layers already use. --- .../datatable/__tests__/useTableData.spec.jsx | 61 ++++++++++++++++++ src/components/datatable/useTableData.js | 24 +++++++ src/constants/layers.js | 1 + .../__tests__/trackedEntityLoader.spec.js | 62 ++++++++++++++++++- src/loaders/trackedEntityLoader.js | 31 +++++++++- 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 17c0ae892a..35ab0fb9a1 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -285,6 +285,67 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('gets headers and rows for tracked entity layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'trackedEntity', + dataFilters: null, + headers: [ + { + name: 'First name', + dataKey: 'w75KJ2mc4zz', + valueType: 'TEXT', + }, + { + name: 'Age', + dataKey: 'zDhUuAYrxNC', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'PsgJS8BUxZd', + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 28, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(3) + expect(headers).toMatchObject([ + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, + { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(3) + expect(rows[0]).toMatchObject([ + { value: 'PsgJS8BUxZd', dataKey: 'id' }, + { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, + { value: 28, dataKey: 'zDhUuAYrxNC' }, + ]) + expect(isLoading).toBe(false) + }) + test('treats NUMBER header with optionSet as string type', () => { const store = { aggregations: {} } const layer = { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index b9a8031573..d6661854a3 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -9,6 +9,7 @@ import { EARTH_ENGINE_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, } from '../../constants/layers.js' import { SELECTION_FILTER_SELECTED, @@ -142,6 +143,26 @@ const getOrgUnitHeaders = () => (field) => defaultFieldsMap()[field] ) +// Unlike getEventHeaders's layerHeaders (raw analytics response shape, +// name=uid/column=display), trackedEntityLoader.js already builds its +// headers in the final {name, dataKey, valueType} shape - only the +// valueType -> table type classification needs doing here. +const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { + const fields = [ID].map((field) => defaultFieldsMap()[field]) + + const customFields = layerHeaders + .filter(({ dataKey }) => isValidUid(dataKey)) + .map(({ name, dataKey, valueType }) => ({ + name, + dataKey, + type: numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + })) + + return fields.concat(customFields) +} + const getFacilityHeaders = () => [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) @@ -288,6 +309,9 @@ export const useTableData = ({ case ORG_UNIT_LAYER: headers = getOrgUnitHeaders() break + case TRACKED_ENTITY_LAYER: + headers = getTrackedEntityHeaders({ layerHeaders }) + break case EARTH_ENGINE_LAYER: headers = getEarthEngineHeaders({ aggregationType, diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..f88c1e1123 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -51,6 +51,7 @@ export const DATA_TABLE_LAYER_TYPES = [ THEMATIC_LAYER, ORG_UNIT_LAYER, EVENT_LAYER, + TRACKED_ENTITY_LAYER, EARTH_ENGINE_LAYER, GEOJSON_URL_LAYER, ] diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 634049846e..f0b39c2fc6 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,9 +1,69 @@ -import { parseJsonConfig } from '../trackedEntityLoader.js' +import { + getAttributeHeaders, + getAttributeProperties, + parseJsonConfig, +} from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) +describe('getAttributeProperties', () => { + it('maps each attribute uid to its value', () => { + const attributes = [ + { attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }, + { attribute: 'zDhUuAYrxNC', value: 'Schmidt' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + w75KJ2mc4zz: 'Gabrielle', + zDhUuAYrxNC: 'Schmidt', + }) + }) + + it('returns an empty object when there are no attributes', () => { + expect(getAttributeProperties(undefined)).toEqual({}) + expect(getAttributeProperties([])).toEqual({}) + }) +}) + +describe('getAttributeHeaders', () => { + it('returns one header per unique attribute uid seen across instances', () => { + const instances = [ + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + ], + }, + { + attributes: [ + { + attribute: 'w75KJ2mc4zz', + displayName: 'First name', + valueType: 'TEXT', + }, + { + attribute: 'zDhUuAYrxNC', + displayName: 'Last name', + valueType: 'TEXT', + }, + ], + }, + ] + expect(getAttributeHeaders(instances)).toEqual([ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Last name', dataKey: 'zDhUuAYrxNC', valueType: 'TEXT' }, + ]) + }) + + it('returns an empty array when no instance has attributes', () => { + expect(getAttributeHeaders([{ attributes: [] }, {}])).toEqual([]) + }) +}) + describe('parseJsonConfig', () => { it('extracts periodType when relationships is null', () => { const config = { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 0a30a3bdce..1f4e7a533c 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -19,7 +19,7 @@ import { import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' -const fields = ['trackedEntity~rename(id)', 'geometry'] +const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes'] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -100,12 +100,36 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } +export const getAttributeProperties = (attributes) => + Object.fromEntries( + (attributes ?? []).map(({ attribute, value }) => [attribute, value]) + ) + +// One header per unique attribute uid seen across all instances - not every +// instance necessarily has a value for every attribute. +export const getAttributeHeaders = (instances) => { + const headersByAttribute = new Map() + instances.forEach(({ attributes }) => { + ;(attributes ?? []).forEach(({ attribute, displayName, valueType }) => { + if (!headersByAttribute.has(attribute)) { + headersByAttribute.set(attribute, { + name: displayName, + dataKey: attribute, + valueType, + }) + } + }) + }) + return [...headersByAttribute.values()] +} + const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ + instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, + ...getAttributeProperties(attributes), }, })) @@ -326,6 +350,8 @@ const trackedEntityLoader = async ({ instance.geometry?.coordinates ) + const headers = getAttributeHeaders(instances) + let alert if (!instances.length) { @@ -362,6 +388,7 @@ const trackedEntityLoader = async ({ ...config, name, data, + headers, keyAnalysisDigitGroupSeparator, relationships, secondaryData, From 7a257d2d48855c6840531d21d87e51db83286bd6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 17 Jul 2026 22:45:12 +0200 Subject: [PATCH 08/47] feat: support timeline/split-by-period thematic layers in the data table Timeline layers get a Value/Legend/Range/Color column for the currently active period, updating live as the slider moves. Both timeline and split-by-period layers can add extra, user-picked period columns via a new "Periods" section in the column picker - split has no default period column since it has no single "current" period the way timeline does. --- src/components/datatable/BottomPanel.jsx | 2 + .../__tests__/ColumnPickerControl.spec.jsx | 114 +++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 181 ++++++++++++++++++ .../controls/ColumnPickerControl.jsx | 74 ++++++- .../styles/ColumnPickerControl.module.css | 47 +++++ src/components/datatable/useTableData.js | 117 ++++++++++- src/loaders/thematicLoader.js | 4 + src/util/__tests__/tableColumns.spec.js | 16 ++ src/util/tableColumns.js | 5 + 9 files changed, 552 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8ff0c7bb70..847cce0ada 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -209,6 +209,8 @@ const BottomPanel = () => { layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} + renderingStrategy={activeLayer?.renderingStrategy} + periods={activeLayer?.periods} /> { visibleKeys: ['name', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -97,6 +102,7 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend', 'rawValue'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -116,6 +122,7 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['rawValue'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -135,6 +142,7 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -189,6 +197,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -208,6 +217,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: [], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -225,6 +235,7 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], }, }) }) @@ -351,6 +362,109 @@ describe('ColumnPicker search', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: [], + }, + }) + }) +}) + +describe('ColumnPicker periods section', () => { + const periods = [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ] + + test('is absent for a single-period (non-multi-period) layer', () => { + renderColumnPicker({ periods }) + openPicker() + expect(screen.queryByText('January 2023')).not.toBeInTheDocument() + }) + + test('is absent when there are no available periods', () => { + renderColumnPicker({ renderingStrategy: RENDERING_STRATEGY_TIMELINE }) + openPicker() + expect(screen.queryByText('Add period columns')).not.toBeInTheDocument() + }) + + test('lists available periods for a timeline layer', () => { + renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + }) + openPicker() + expect(screen.getByText('Add period columns')).toBeInTheDocument() + expect(screen.getByLabelText('January 2023')).not.toBeChecked() + expect(screen.getByLabelText('February 2023')).not.toBeChecked() + }) + + test('lists available periods for a split-by-period layer too', () => { + renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_SPLIT_BY_PERIOD, + periods, + }) + openPicker() + expect(screen.getByText('Add period columns')).toBeInTheDocument() + }) + + test('checking a period dispatches extraPeriodIds with it added', () => { + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + }) + openPicker() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202301'], + }, + }) + }) + + test('checking a period also adds its dataKey to an already-customized visibleKeys allowlist', () => { + // visibleKeys, once customized, acts as an allowlist (getVisibleHeaders + // filters out anything not in it) - the new period column's dataKey + // must be added too, or it would never actually render. + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + columnConfig: { visibleKeys: ['name'] }, + }) + openPicker() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'period_202301_rawValue'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202301'], + }, + }) + }) + + test('unchecking an already-added period dispatches extraPeriodIds without it', () => { + const { store } = renderColumnPicker({ + renderingStrategy: RENDERING_STRATEGY_TIMELINE, + periods, + columnConfig: { extraPeriodIds: ['202301', '202302'] }, + }) + openPicker() + expect(screen.getByLabelText('January 2023')).toBeChecked() + fireEvent.click(screen.getByLabelText('January 2023')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + extraPeriodIds: ['202302'], }, }) }) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 35ab0fb9a1..8620d18f65 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -189,6 +189,187 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + externalPeriod: { id: '202302', name: 'February 2023' }, + valuesByPeriod: { + 202301: { + 'ou-1': { value: 100, color: '#aaaaaa', legend: 'Low' }, + }, + 202302: { + 'ou-1': { + value: 200, + color: '#bbbbbb', + legend: 'High', + range: '150 – 250', + }, + }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Value (February 2023)', dataKey: 'rawValue' }, + { name: 'Legend (February 2023)', dataKey: 'legend' }, + { name: 'Range (February 2023)', dataKey: 'range' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { name: 'Color (February 2023)', dataKey: 'color' }, + ]) + expect(rows[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 200, dataKey: 'rawValue' }), + expect.objectContaining({ value: 'High', dataKey: 'legend' }), + expect.objectContaining({ + value: '150 – 250', + dataKey: 'range', + }), + ]) + ) + }) + + test('adds a raw-value-only extra period column for a timeline thematic layer', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'TIMELINE', + externalPeriod: { id: '202302', name: 'February 2023' }, + periods: [ + { id: '202301', name: 'January 2023' }, + { id: '202302', name: 'February 2023' }, + ], + dataTableColumnConfig: { extraPeriodIds: ['202301'] }, + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + 202302: { 'ou-1': { value: 200 } }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + type: 'number', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + + test('split-by-period thematic layer has no default current-period column, only extras', () => { + const store = { aggregations: {} } + const layer = { + layer: 'thematic', + renderingStrategy: 'SPLIT_BY_PERIOD', + externalPeriod: { id: '202302', name: 'February 2023' }, + periods: [{ id: '202301', name: 'January 2023' }], + dataTableColumnConfig: { extraPeriodIds: ['202301'] }, + valuesByPeriod: { + 202301: { 'ou-1': { value: 100 } }, + 202302: { + 'ou-1': { value: 200, color: '#bbbbbb', legend: 'High' }, + }, + }, + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Ngelehun CHC', + type: 'Point', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toMatchObject([ + { name: 'Name', dataKey: 'name' }, + { name: 'Id', dataKey: 'id' }, + { name: 'Level', dataKey: 'level' }, + { name: 'Parent', dataKey: 'parentName' }, + { name: 'Type', dataKey: 'type' }, + { + name: 'Value (January 2023)', + dataKey: 'period_202301_rawValue', + }, + ]) + expect(rows[0]).not.toContainEqual( + expect.objectContaining({ dataKey: 'rawValue' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 100, + dataKey: 'period_202301_rawValue', + }) + ) + }) + test('gets headers and rows for event layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 6f9e52ad61..2033bf59c7 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -23,11 +23,13 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' +import { RENDERING_STRATEGY_SINGLE } from '../../../constants/layers.js' import { getPinnedCount, getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, + togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../../../util/tableColumns.js' @@ -38,7 +40,13 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 -const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = ({ + layerId, + allHeaders, + columnConfig, + renderingStrategy, + periods, +}) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -79,6 +87,9 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { }) const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) + const extraPeriodIds = columnConfig?.extraPeriodIds ?? [] + const isMultiPeriodThematic = + renderingStrategy && renderingStrategy !== RENDERING_STRATEGY_SINGLE const updateConfig = (partial) => dispatch( @@ -86,6 +97,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { visibleKeys, pinnedKeys, orderedKeys, + extraPeriodIds, ...partial, }) ) @@ -112,6 +124,25 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) + const onTogglePeriodId = (periodId) => { + const isAdding = !extraPeriodIds.includes(periodId) + updateConfig({ + extraPeriodIds: togglePeriodId(extraPeriodIds, periodId), + // A newly-added period's column only has a header once + // useTableData sees the updated extraPeriodIds - but + // visibleKeys, once customized, is an allowlist, so its new + // dataKey needs adding here too or the column would never + // actually render. + ...(isAdding && + columnConfig?.visibleKeys && { + visibleKeys: [ + ...visibleKeys, + `period_${periodId}_rawValue`, + ], + }), + }) + } + const filteredHeaders = orderedHeaders.filter((h) => h.name.toLowerCase().includes(search.trim().toLowerCase()) ) @@ -287,6 +318,39 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { document.body )} + {isMultiPeriodThematic && periods?.length > 0 && ( +
+

+ {i18n.t('Add period columns')} +

+
+ {periods.map(({ id, name }) => ( + + ))} +
+
+ )} )} @@ -303,10 +367,18 @@ ColumnPickerControl.propTypes = { }) ), columnConfig: PropTypes.shape({ + extraPeriodIds: PropTypes.arrayOf(PropTypes.string), orderedKeys: PropTypes.arrayOf(PropTypes.string), pinnedKeys: PropTypes.arrayOf(PropTypes.string), visibleKeys: PropTypes.arrayOf(PropTypes.string), }), + periods: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + }) + ), + renderingStrategy: PropTypes.string, } export default ColumnPickerControl diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index fa974e6fd3..6c56ce200e 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -137,3 +137,50 @@ background-color: var(--colors-white); box-shadow: var(--elevations-popover); } + +.periodsSection { + margin-top: var(--spacers-dp8); + padding-top: var(--spacers-dp8); + border-top: 1px solid var(--colors-grey300); +} + +.periodsSectionLabel { + margin: 0 0 var(--spacers-dp4); + font-size: 11px; + font-weight: 600; + color: var(--colors-grey700); +} + +.periodsList { + display: flex; + flex-direction: column; + max-height: 150px; + overflow-y: auto; +} + +.periodRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: var(--spacers-dp2) var(--spacers-dp4); + border-radius: 3px; + cursor: pointer; +} + +.periodRow:hover { + background: var(--colors-grey100); +} + +.periodRow input[type='checkbox'] { + flex-shrink: 0; + accent-color: var(--colors-teal600); +} + +.periodRowLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d6661854a3..01b8c2688f 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -10,6 +10,8 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, TRACKED_ENTITY_LAYER, + RENDERING_STRATEGY_SINGLE, + RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' import { SELECTION_FILTER_SELECTED, @@ -104,6 +106,44 @@ const getThematicHeaders = () => (field) => defaultFieldsMap()[field] ) +// Timeline gets the standard Value/Legend/Range/Color columns, relabeled +// with the active period's name (updates live as the timeline slider +// moves). Split-by-period has no single "current" period to privilege, so +// it only gets the base org unit columns - same shape as getOrgUnitHeaders. +// Both strategies can add extra, raw-value-only period columns via the +// column picker's "Periods" section. +const getMultiPeriodThematicHeaders = ({ + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, +}) => { + const headers = isTimelineThematic + ? getThematicHeaders().map((header) => + [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) + ? { + ...header, + name: `${header.name} (${ + externalPeriod?.name ?? i18n.t('Current period') + })`, + } + : header + ) + : getOrgUnitHeaders() + + extraPeriodIds.forEach((periodId) => { + const periodName = + periods?.find((p) => p.id === periodId)?.name ?? periodId + headers.push({ + name: i18n.t('Value ({{period}})', { period: periodName }), + dataKey: `period_${periodId}_rawValue`, + type: TYPE_NUMBER, + }) + }) + + return headers +} + const getEventHeaders = ({ layerHeaders = [], styleDataItem, @@ -238,8 +278,25 @@ export const useTableData = ({ dataFilters, headers: layerHeaders, serverCluster, + renderingStrategy, + valuesByPeriod, + externalPeriod, + periods, + dataTableColumnConfig, } = layer || EMPTY_LAYER + const isMultiPeriodThematic = + layerType === THEMATIC_LAYER && + renderingStrategy && + renderingStrategy !== RENDERING_STRATEGY_SINGLE + const isTimelineThematic = + isMultiPeriodThematic && + renderingStrategy === RENDERING_STRATEGY_TIMELINE + const extraPeriodIds = useMemo( + () => dataTableColumnConfig?.extraPeriodIds ?? [], + [dataTableColumnConfig] + ) + // Only depend on mapBounds while the toggle is on, so panning/zooming // doesn't recompute dataWithAggregations below when it's off const boundsDependency = showOnlyFeaturesInView ? mapBounds : null @@ -272,12 +329,41 @@ export const useTableData = ({ return inViewData .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is set - index, - })) + .map((d, index) => { + const properties = d.properties || d + + if (!isMultiPeriodThematic) { + return { + ...properties, + ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set + index, + } + } + + const orgUnitId = properties.id + const currentPeriodItem = isTimelineThematic + ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] + : null + const extraPeriodValues = {} + extraPeriodIds.forEach((pid) => { + extraPeriodValues[`period_${pid}_rawValue`] = + valuesByPeriod?.[pid]?.[orgUnitId]?.value ?? null + }) + + return { + ...properties, + ...(currentPeriodItem && { + rawValue: currentPeriodItem.value, + color: currentPeriodItem.color, + legend: currentPeriodItem.legend, + range: currentPeriodItem.range, + }), + ...extraPeriodValues, + ...aggregations[d.id], + index, + } + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ data, @@ -287,6 +373,11 @@ export const useTableData = ({ layerType, showOnlyFeaturesInView, boundsDependency, + isMultiPeriodThematic, + isTimelineThematic, + valuesByPeriod, + externalPeriod, + extraPeriodIds, ]) const headers = useMemo(() => { @@ -297,7 +388,14 @@ export const useTableData = ({ let headers = null switch (layerType) { case THEMATIC_LAYER: - headers = getThematicHeaders() + headers = isMultiPeriodThematic + ? getMultiPeriodThematicHeaders({ + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, + }) + : getThematicHeaders() break case EVENT_LAYER: headers = getEventHeaders({ @@ -354,6 +452,11 @@ export const useTableData = ({ dataWithAggregations, data, layerHeaders, + isMultiPeriodThematic, + isTimelineThematic, + externalPeriod, + extraPeriodIds, + periods, ]) const columnOptions = useMemo(() => { diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 1e3dba4a08..5d01d4fdbd 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -544,6 +544,10 @@ const thematicLoader = async ({ isNoData, isUnclassified, }), + ...getFeatureLegend(legendItem, { + isNoData, + isUnclassified, + }), ...getFeatureRadius( legendItem, { isNoData, isUnclassified }, diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index 77db56a701..b76c71825b 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -5,6 +5,7 @@ import { getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, + togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../tableColumns.js' @@ -301,3 +302,18 @@ describe('getPinnedCellProps', () => { }) }) }) + +describe('togglePeriodId', () => { + it('adds a period id when it is not yet added', () => { + expect(togglePeriodId(['202301'], '202302')).toEqual([ + '202301', + '202302', + ]) + }) + + it('removes a period id when it is already added', () => { + expect(togglePeriodId(['202301', '202302'], '202301')).toEqual([ + '202302', + ]) + }) +}) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index b6e86f87c3..8980ae0a0e 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -68,6 +68,11 @@ export const reverseVisibleKeys = (headers, visibleKeys) => .filter((h) => !visibleKeys.includes(h.dataKey)) .map((h) => h.dataKey) +export const togglePeriodId = (extraPeriodIds, periodId) => + extraPeriodIds.includes(periodId) + ? extraPeriodIds.filter((id) => id !== periodId) + : [...extraPeriodIds, periodId] + // @dhis2/ui requires `width` whenever `fixed` is passed export const getPinnedCellProps = ( dataKey, From 5083c80b1b05d9b260fa69f0dda1783552fddf12 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 18 Jul 2026 05:37:17 +0200 Subject: [PATCH 09/47] feat: add Legend/Range columns for data-item-styled event layers Event layers styled by a data item already computed per-feature color/colorGroup; this surfaces the matching legend item's name/range as data table columns too, alongside the existing Color column. --- src/components/datatable/DataTable.jsx | 1 + .../datatable/__tests__/useTableData.spec.jsx | 186 ++++++++++++++++++ src/components/datatable/useTableData.js | 40 +++- 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 729053a30b..0fcfb68480 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -177,6 +177,7 @@ const Table = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) useEffect(() => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 8620d18f65..45d9bb2847 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -618,6 +618,192 @@ describe('useTableData headers', () => { expect(scoreHeader.type).toBe('number') }) + test('adds Legend/Range/Color columns for an event layer styled by a numeric data item', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legend: { + items: [ + { + name: 'Low', + color: '#aaaaaa', + startValue: 0, + endValue: 50, + colorGroup: 0, + }, + { + name: 'High', + color: '#bbbbbb', + startValue: 50, + endValue: 100, + colorGroup: 1, + }, + ], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 1, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Legend', + dataKey: 'legend', + type: 'string', + }) + expect(headers).toContainEqual({ + name: 'Range', + dataKey: 'range', + type: 'string', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'High', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '50 – 100', dataKey: 'range' }) + ) + }) + + test('formats an event layer’s Range using the layer’s own legendDecimalPlaces', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK' }, + legendDecimalPlaces: 1, + legend: { + items: [ + { + name: 'High', + color: '#bbbbbb', + startValue: 50.256, + endValue: 100.789, + colorGroup: 0, + }, + ], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Score', valueType: 'NUMBER' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 75, + value: 75, + color: '#bbbbbb', + colorGroup: 0, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.rows[0]).toContainEqual( + expect.objectContaining({ value: '50.3 – 100.8', dataKey: 'range' }) + ) + }) + + test('leaves Range empty for an event layer styled by a non-numeric (option set) data item', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + styleDataItem: { id: 'AbCdEfGhIjK', optionSet: { id: 'os1' } }, + legend: { + items: [{ name: 'Yes', color: '#00ff00', colorGroup: 0 }], + }, + headers: [ + { name: 'AbCdEfGhIjK', column: 'Answer', valueType: 'TEXT' }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'Yes', + value: 'Yes', + color: '#00ff00', + colorGroup: 0, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { rows } = result.current + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: 'Yes', dataKey: 'legend' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: undefined, dataKey: 'range' }) + ) + }) + test('gets headers and rows for EE population layer', () => { const store = { aggregations: { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 01b8c2688f..da3d1fa536 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -21,7 +21,11 @@ import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' -import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { + formatRangeWithSeparator, + getRoundToPrecisionFn, + getPrecision, +} from '../../util/numbers.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' import { isValidUid } from '../../util/uid.js' @@ -172,7 +176,11 @@ const getEventHeaders = ({ customFields.push(defaultFieldsMap()[TYPE]) if (styleDataItem) { - customFields.push(defaultFieldsMap()[COLOR]) + customFields.push( + defaultFieldsMap()[LEGEND], + defaultFieldsMap()[RANGE], + defaultFieldsMap()[COLOR] + ) } return fields.concat(customFields) @@ -261,6 +269,7 @@ export const useTableData = ({ selectionFilter, selectedIdSet, globalSearch, + keyAnalysisDigitGroupSeparator, }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -283,6 +292,7 @@ export const useTableData = ({ externalPeriod, periods, dataTableColumnConfig, + legendDecimalPlaces, } = layer || EMPTY_LAYER const isMultiPeriodThematic = @@ -296,6 +306,7 @@ export const useTableData = ({ () => dataTableColumnConfig?.extraPeriodIds ?? [], [dataTableColumnConfig] ) + const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem // Only depend on mapBounds while the toggle is on, so panning/zooming // doesn't recompute dataWithAggregations below when it's off @@ -332,6 +343,27 @@ export const useTableData = ({ .map((d, index) => { const properties = d.properties || d + if (isStyledEvent) { + // The event's own styling pass already classified this + // feature into legend.items[colorGroup] (color/radius) - + // Legend/Range are just a lookup, not new classification. + const legendItem = legend?.items?.[properties.colorGroup] + return { + ...properties, + legend: legendItem?.name, + range: + legendItem && 'startValue' in legendItem + ? formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : undefined, + ...aggregations[d.id], + index, + } + } + if (!isMultiPeriodThematic) { return { ...properties, @@ -378,6 +410,10 @@ export const useTableData = ({ valuesByPeriod, externalPeriod, extraPeriodIds, + isStyledEvent, + legend, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, ]) const headers = useMemo(() => { From 8162d5033e2bc6ce997539efbe7ffddf8ad35836 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 18 Jul 2026 05:49:23 +0200 Subject: [PATCH 10/47] feat: add Color/Icon/Group columns for group-set-styled org unit and facility layers Surfaces the color/icon/group data group-set styling already computes per feature as data table columns, data-driven so each column only appears when the current styling actually produced it. Adds a new image-thumbnail cell type to the data table for the Icon column. --- src/components/datatable/DataTable.jsx | 25 +++-- .../datatable/__tests__/useTableData.spec.jsx | 100 ++++++++++++++++++ .../datatable/styles/DataTable.module.css | 7 ++ src/components/datatable/useTableData.js | 45 ++++++-- src/util/__tests__/orgUnits.spec.js | 1 + src/util/orgUnits.js | 13 ++- 6 files changed, 173 insertions(+), 18 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 0fcfb68480..bb85c7a3b9 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -588,12 +588,25 @@ const Table = ({ } align={align} > - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} + {dataKey === 'color' && + value?.toLowerCase()} + {dataKey === 'iconUrl' && value && ( + { + e.target.style.visibility = + 'hidden' + }} + /> + )} + {dataKey !== 'color' && + dataKey !== 'iconUrl' && + formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} ) })} diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 45d9bb2847..b2b4f74129 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -64,6 +64,106 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('adds an Icon column for a facility layer styled by group set symbol', () => { + const store = { aggregations: {} } + const layer = { + layer: 'facility', + dataFilters: null, + data: [ + { + properties: { + id: 'facility-1', + name: 'Facility 1', + type: 'Point', + iconUrl: 'https://server/images/orgunitgroup/1.png', + group: 'Hospitals', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Icon', + dataKey: 'iconUrl', + type: 'string', + renderer: 'rendericon', + }) + expect(headers).toContainEqual({ + name: 'Group', + dataKey: 'group', + type: 'string', + }) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'color' }) + ) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 'https://server/images/orgunitgroup/1.png', + dataKey: 'iconUrl', + }) + ) + }) + + test('adds a Color column for an orgUnit layer styled by group set color', () => { + const store = { aggregations: {} } + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'ou-1', + name: 'Bo District', + type: 'MultiPolygon', + level: 2, + color: '#ff0000', + group: 'Rural', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Color', dataKey: 'color' }) + ) + expect(headers).toContainEqual( + expect.objectContaining({ name: 'Group', dataKey: 'group' }) + ) + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'iconUrl' }) + ) + }) + test('gets headers and rows for orgUnit layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 8f6c8de3f4..41cb48405d 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -22,6 +22,13 @@ th.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconCell { + display: block; + width: 20px; + height: 20px; + object-fit: contain; +} + th.checkboxCell, td.checkboxCell { width: 76px; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index da3d1fa536..38fd0af8ea 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -42,6 +42,8 @@ const LEVEL = 'level' const PARENT_NAME = 'parentName' const TYPE = 'type' const COLOR = 'color' +const GROUP = 'group' +const ICON = 'iconUrl' const OUNAME = 'ouname' const OUBOUNDARY = 'ouBoundary' const EVENTDATE = 'eventdate' @@ -103,6 +105,13 @@ const defaultFieldsMap = () => ({ type: TYPE_STRING, renderer: 'rendercolor', }, + [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, + [ICON]: { + name: i18n.t('Icon'), + dataKey: ICON, + type: TYPE_STRING, + renderer: 'rendericon', + }, }) const getThematicHeaders = () => @@ -186,10 +195,28 @@ const getEventHeaders = ({ return fields.concat(customFields) } -const getOrgUnitHeaders = () => - [NAME, ID, LEVEL, PARENT_NAME, TYPE].map( - (field) => defaultFieldsMap()[field] - ) +// Facility/org unit layers only get Color/Icon/Group columns when the +// current group-set styling actually produced them - style type (and +// whether every org unit matched a group) isn't known up front, so this +// checks the resolved row data rather than re-deriving that logic here. +const getGroupSetStyleHeaders = (data) => { + const headers = [] + if (data?.some((d) => d.color != null)) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (data?.some((d) => d.iconUrl != null)) { + headers.push(defaultFieldsMap()[ICON]) + } + if (data?.some((d) => d.group != null)) { + headers.push(defaultFieldsMap()[GROUP]) + } + return headers +} + +const getOrgUnitHeaders = (data) => + [NAME, ID, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(getGroupSetStyleHeaders(data)) // Unlike getEventHeaders's layerHeaders (raw analytics response shape, // name=uid/column=display), trackedEntityLoader.js already builds its @@ -211,8 +238,10 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { return fields.concat(customFields) } -const getFacilityHeaders = () => - [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) +const getFacilityHeaders = (data) => + [NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(getGroupSetStyleHeaders(data)) const toTitleCase = (str) => str.replace( @@ -441,7 +470,7 @@ export const useTableData = ({ }) break case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders() + headers = getOrgUnitHeaders(dataWithAggregations) break case TRACKED_ENTITY_LAYER: headers = getTrackedEntityHeaders({ layerHeaders }) @@ -454,7 +483,7 @@ export const useTableData = ({ }) break case FACILITY_LAYER: - headers = getFacilityHeaders() + headers = getFacilityHeaders(dataWithAggregations) break case GEOJSON_URL_LAYER: { if ( diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 3a7f54155b..55362e6e8d 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -358,6 +358,7 @@ describe('getStyledOrgUnits', () => { expect(result.legend.items).toContainEqual( expect.objectContaining({ name: 'Unclassified', color: '#cccccc' }) ) + expect(result.styledFeatures[0].properties.group).toBe('Group1') }) it('should include unclassified orgunit with unclassifiedLegend color when set', () => { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2c6d4dbfcb..746217ae19 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -159,10 +159,11 @@ export const getStyledOrgUnits = ({ .map((f) => { const isPoint = f.geometry.type === 'Point' const { hasAdditionalGeometry } = f.properties - const { color, symbol } = getOrgUnitStyle( - f.properties.dimensions, - groupSet - ) + const { + name: groupName, + color, + symbol, + } = getOrgUnitStyle(f.properties.dimensions, groupSet) const isUnclassified = !!groupSet.id && !color && !symbol let radius @@ -187,6 +188,10 @@ export const getStyledOrgUnits = ({ properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` } + if (groupName) { + properties.group = groupName + } + if (properties.level && levelWeight) { properties.weight = levelWeight(f.properties.level) } From 5f23bef4d00e19e08fcee361c7b99fb67573abac Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 18 Jul 2026 10:00:01 +0200 Subject: [PATCH 11/47] feat: add Color column for GeoJSON URL and tracked entity layers GeoJSON URL layers get a per-geometry-type color, matching the map legend, without ever overwriting a feature's own pre-existing color (maps-gl's colorExpr already prefers that over the layer's uniform style). Tracked entity layers get their fixed point color - coarse today, but the column now exists for when TE styling gains real per-instance classification. --- .../datatable/__tests__/useTableData.spec.jsx | 50 ++++++++++++- src/components/datatable/useTableData.js | 9 ++- .../__tests__/geoJsonUrlLoader.spec.js | 70 +++++++++++++++++++ .../__tests__/trackedEntityLoader.spec.js | 27 +++++++ src/loaders/geoJsonUrlLoader.js | 24 ++++++- src/loaders/trackedEntityLoader.js | 16 +++-- 6 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/loaders/__tests__/geoJsonUrlLoader.spec.js diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index b2b4f74129..f231acd0b6 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -591,6 +591,7 @@ describe('useTableData headers', () => { id: 'PsgJS8BUxZd', w75KJ2mc4zz: 'Gabrielle', zDhUuAYrxNC: 28, + color: '#e57200', }, }, ], @@ -611,18 +612,20 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(3) + expect(headers).toHaveLength(4) expect(headers).toMatchObject([ { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' }, { name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' }, + { name: 'Color', dataKey: 'color', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(3) + expect(rows[0]).toHaveLength(4) expect(rows[0]).toMatchObject([ { value: 'PsgJS8BUxZd', dataKey: 'id' }, { value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, { value: 28, dataKey: 'zDhUuAYrxNC' }, + { value: '#e57200', dataKey: 'color' }, ]) expect(isLoading).toBe(false) }) @@ -1148,6 +1151,49 @@ describe('useTableData headers', () => { ]) expect(isLoading).toBe(false) }) + + test('gets headers and rows for a geoJsonUrl layer, labeling the synthetic color property "Color"', () => { + const store = { aggregations: {} } + const layer = { + layer: 'geoJsonUrl', + dataFilters: null, + data: [ + { + geometry: { type: 'Point' }, + properties: { + id: 'feature-1', + name: 'Feature 1', + color: '#ff0000', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Color', + dataKey: 'color', + type: 'string', + renderer: 'rendercolor', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ value: '#ff0000', dataKey: 'color' }) + ) + }) }) describe('useTableData sorting', () => { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 38fd0af8ea..9e4dc65e60 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -235,6 +235,8 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { : TYPE_STRING, })) + customFields.push(defaultFieldsMap()[COLOR]) + return fields.concat(customFields) } @@ -282,8 +284,13 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { .concat(customFields) } +// The synthetic per-geometry-type `color` property gets the same +// canonical, translated Color header every other layer type uses, +// rather than being treated as just another arbitrary uploaded field. const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem) + getGeojsonDisplayData(firstDataItem).map((header) => + header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header + ) const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} diff --git a/src/loaders/__tests__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js new file mode 100644 index 0000000000..e3d655d858 --- /dev/null +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -0,0 +1,70 @@ +import { stampFeatureColors } from '../geoJsonUrlLoader.js' + +describe('stampFeatureColors', () => { + it('stamps each feature with its matching geometry-type color', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + { geometry: { type: 'Polygon' }, properties: { id: '2' } }, + ] + const legendItemsByType = { + Point: { color: '#ff0000' }, + Polygon: { color: '#00ff00' }, + } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#ff0000') + expect(result[1].properties.color).toBe('#00ff00') + }) + + it('normalizes Multi* geometry types to their base type before matching', () => { + const features = [ + { geometry: { type: 'MultiPolygon' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Polygon: { color: '#00ff00' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#00ff00') + }) + + it('leaves a feature unchanged when its geometry type has no matching color', () => { + const features = [ + { geometry: { type: 'LineString' }, properties: { id: '1' } }, + ] + + const result = stampFeatureColors(features, {}) + + expect(result[0].properties.color).toBeUndefined() + expect(result[0]).toEqual(features[0]) + }) + + it('does not mutate the original feature objects', () => { + const features = [ + { geometry: { type: 'Point' }, properties: { id: '1' } }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + stampFeatureColors(features, legendItemsByType) + + expect(features[0].properties.color).toBeUndefined() + }) + + it('never overwrites a feature that already has its own color', () => { + // maps-gl's colorExpr prefers a feature's own properties.color over + // the layer's uniform style color, so a user-uploaded file with its + // own per-feature colors must keep rendering with them. + const features = [ + { + geometry: { type: 'Point' }, + properties: { id: '1', color: '#123456' }, + }, + ] + const legendItemsByType = { Point: { color: '#ff0000' } } + + const result = stampFeatureColors(features, legendItemsByType) + + expect(result[0].properties.color).toBe('#123456') + expect(result[0]).toBe(features[0]) + }) +}) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index f0b39c2fc6..e25cda3c91 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -2,6 +2,7 @@ import { getAttributeHeaders, getAttributeProperties, parseJsonConfig, + toGeoJson, } from '../trackedEntityLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -114,3 +115,29 @@ describe('parseJsonConfig', () => { expect(config.config).toBeUndefined() }) }) + +describe('toGeoJson', () => { + it('stamps the given color onto every instance, alongside its id and attributes', () => { + const instances = [ + { + id: 'tei-1', + geometry: { type: 'Point', coordinates: [1, 2] }, + attributes: [{ attribute: 'w75KJ2mc4zz', value: 'Gabrielle' }], + }, + ] + + const result = toGeoJson(instances, '#ff0000') + + expect(result).toEqual([ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { + id: 'tei-1', + color: '#ff0000', + w75KJ2mc4zz: 'Gabrielle', + }, + }, + ]) + }) +}) diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index ed26dbce8a..f601e7bae3 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -7,6 +7,22 @@ import { GEO_TYPE_POLYGON, } from '../util/geojson.js' +// features of different (non-Multi-normalized) geometry types get their +// own color, matching the map legend's own per-type color - never +// overwrites a feature's own pre-existing color (maps-gl's colorExpr +// already prefers a per-feature properties.color over the layer's +// uniform style color, so a feature that already has one is rendered +// with it, and the data table should reflect the same real color). +export const stampFeatureColors = (features, legendItemsByType) => + features.map((f) => { + if (f.properties.color != null) { + return f + } + const nonMultiType = f.geometry.type.replaceAll('Multi', '') + const color = legendItemsByType[nonMultiType]?.color + return color ? { ...f, properties: { ...f.properties, color } } : f + }) + const fetchData = async (url, engine, baseUrl) => { if (url.includes(baseUrl)) { // API route, use engine @@ -92,9 +108,9 @@ const geoJsonUrlLoader = async ({ } if (!loadError) { const { featureCollection, types } = buildGeoJsonFeatures(geoJson) - data = featureCollection const oneType = types.length === 1 + const legendItemsByType = {} types.forEach((type) => { let legendItem @@ -122,7 +138,13 @@ const geoJsonUrlLoader = async ({ } } legend.items.push(legendItem) + legendItemsByType[type] = legendItem }) + + // A per-geometry-type color, for the data table's Color column - + // features of different types in the same file get different + // colors here, matching what the map legend already shows per type. + data = stampFeatureColors(featureCollection, legendItemsByType) } return { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 1f4e7a533c..4431dfeec8 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -123,12 +123,17 @@ export const getAttributeHeaders = (instances) => { return [...headersByAttribute.values()] } -const toGeoJson = (instances) => +// The main tracked entity marker's own color is currently fixed for every +// instance (no per-instance classification yet, unlike thematic/event) - +// still stamped here so the data table's Color column has real data ready +// to become meaningful once that changes. +export const toGeoJson = (instances, color) => instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, + color, ...getAttributeProperties(attributes), }, })) @@ -174,6 +179,7 @@ const fetchRelationshipData = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, }) => { const { relationshipType } = await engine.query( @@ -222,7 +228,7 @@ const fetchRelationshipData = async ({ }) return { - data: toGeoJson(dataWithRels.primary), + data: toGeoJson(dataWithRels.primary, pointColor), relationships: dataWithRels.relationships, secondaryData: toGeoJson(dataWithRels.secondary), } @@ -290,6 +296,7 @@ const trackedEntityLoader = async ({ } = config const name = program ? program.name : i18n.t('Tracked entity') + const pointColor = eventPointColor || TEI_COLOR const legend = { title: name, @@ -302,7 +309,7 @@ const trackedEntityLoader = async ({ name: trackedEntityType.name + (areaRadius ? ` + ${areaRadius} ${'m'} ${'buffer'}` : ''), - color: eventPointColor || TEI_COLOR, + color: pointColor, radius: eventPointRadius || TEI_RADIUS, }, ], @@ -374,10 +381,11 @@ const trackedEntityLoader = async ({ relatedPointColor, relatedPointRadius, relationshipLineColor, + pointColor, legend, })) } else { - data = toGeoJson(instances) + data = toGeoJson(instances, pointColor) } if (explanation) { From 8eb2174b037a5b78803040689e3afd9bbdd6965f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 19 Jul 2026 11:24:25 +0200 Subject: [PATCH 12/47] chore: pr cleanup --- src/actions/__tests__/dataTable.spec.js | 11 ++ src/actions/dataTable.js | 5 + src/components/datatable/BottomPanel.jsx | 2 - src/components/datatable/FilterInput.jsx | 23 ++- .../__tests__/ColumnPickerControl.spec.jsx | 126 ++++------------ .../datatable/__tests__/FilterInput.spec.jsx | 18 +++ .../datatable/__tests__/useTableData.spec.jsx | 50 ++++--- .../controls/ColumnPickerControl.jsx | 81 +---------- .../styles/ColumnPickerControl.module.css | 47 ------ .../datatable/styles/FilterInput.module.css | 17 +++ src/components/datatable/useTableData.js | 135 +++++++++++------- src/components/map/Map.jsx | 8 +- src/components/map/MapContainer.jsx | 4 + src/constants/actionTypes.js | 1 + src/reducers/__tests__/ui.spec.js | 16 +++ src/reducers/ui.js | 7 + src/util/__tests__/tableColumns.spec.js | 100 +++++++++++-- src/util/__tests__/tableSort.spec.js | 18 +++ src/util/tableColumns.js | 40 ++++-- src/util/tableSort.js | 11 +- 20 files changed, 402 insertions(+), 318 deletions(-) diff --git a/src/actions/__tests__/dataTable.spec.js b/src/actions/__tests__/dataTable.spec.js index bc50b2763d..217c86a66c 100644 --- a/src/actions/__tests__/dataTable.spec.js +++ b/src/actions/__tests__/dataTable.spec.js @@ -3,6 +3,7 @@ import { closeDataTable, toggleDataTable, resizeDataTable, + setActiveTimelinePeriod, } from '../dataTable.js' describe('closeDataTable', () => { @@ -30,3 +31,13 @@ describe('resizeDataTable', () => { }) }) }) + +describe('setActiveTimelinePeriod', () => { + it('creates an ACTIVE_TIMELINE_PERIOD_SET action', () => { + const period = { id: '202301', name: 'January 2023' } + expect(setActiveTimelinePeriod(period)).toEqual({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + }) +}) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index ceb7ed56e3..281c7e9cef 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -38,3 +38,8 @@ export const setDataTableColumnConfig = (layerId, config) => ({ layerId, config, }) + +export const setActiveTimelinePeriod = (period) => ({ + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, +}) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 847cce0ada..8ff0c7bb70 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -209,8 +209,6 @@ const BottomPanel = () => { layerId={activeLayerId} allHeaders={allHeaders} columnConfig={activeLayer?.dataTableColumnConfig} - renderingStrategy={activeLayer?.renderingStrategy} - periods={activeLayer?.periods} /> + isIconColumn ? ( + + { + e.target.style.visibility = 'hidden' + }} + /> + {value.split('/').pop()} + + ) : ( + resolveLabel(value) + ) + const hasNotSetOption = options.some( ({ value }) => value === SENTINEL_NO_VALUE ) @@ -401,7 +420,9 @@ const SearchableFilterPopover = ({ computeItemKey={(_, option) => option.value} itemContent={(index, option) => ( { visibleKeys: ['name', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -102,7 +97,6 @@ describe('ColumnPicker visibility toggling', () => { visibleKeys: ['name', 'legend', 'rawValue'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -122,7 +116,6 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['rawValue'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -142,7 +135,6 @@ describe('ColumnPicker pinning', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -197,7 +189,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -217,7 +208,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: [], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -235,7 +225,6 @@ describe('ColumnPicker bulk actions', () => { visibleKeys: ['rawValue', 'legend'], pinnedKeys: ['legend'], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) @@ -362,109 +351,54 @@ describe('ColumnPicker search', () => { visibleKeys: ['name', 'rawValue', 'legend'], pinnedKeys: [], orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: [], }, }) }) }) -describe('ColumnPicker periods section', () => { - const periods = [ - { id: '202301', name: 'January 2023' }, - { id: '202302', name: 'February 2023' }, +describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { + // Period columns exist as regular headers for every available period, + // but start out unchecked - same mechanism as any other column, no + // dedicated "add period" UI. A defaultHidden header exercises that + // exact path without needing a real thematic/timeline layer fixture. + const headersWithHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, ] - test('is absent for a single-period (non-multi-period) layer', () => { - renderColumnPicker({ periods }) + test('appears in the main list, unchecked, when there is no saved config yet', () => { + renderColumnPicker({ allHeaders: headersWithHiddenColumn }) openPicker() - expect(screen.queryByText('January 2023')).not.toBeInTheDocument() + expect(screen.getByLabelText('Value (Jan 2023)')).not.toBeChecked() }) - test('is absent when there are no available periods', () => { - renderColumnPicker({ renderingStrategy: RENDERING_STRATEGY_TIMELINE }) - openPicker() - expect(screen.queryByText('Add period columns')).not.toBeInTheDocument() - }) - - test('lists available periods for a timeline layer', () => { - renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - }) - openPicker() - expect(screen.getByText('Add period columns')).toBeInTheDocument() - expect(screen.getByLabelText('January 2023')).not.toBeChecked() - expect(screen.getByLabelText('February 2023')).not.toBeChecked() - }) - - test('lists available periods for a split-by-period layer too', () => { - renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_SPLIT_BY_PERIOD, - periods, - }) - openPicker() - expect(screen.getByText('Add period columns')).toBeInTheDocument() - }) - - test('checking a period dispatches extraPeriodIds with it added', () => { + test('checking it dispatches visibleKeys with its dataKey added, alongside the other default-visible columns', () => { const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, + allHeaders: headersWithHiddenColumn, }) openPicker() - fireEvent.click(screen.getByLabelText('January 2023')) + fireEvent.click(screen.getByLabelText('Value (Jan 2023)')) expect(store.getActions()).toContainEqual({ type: DATA_TABLE_COLUMN_CONFIG_SET, layerId: 'layer1', config: { - visibleKeys: ['name', 'rawValue', 'legend'], + visibleKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202301'], - }, - }) - }) - - test('checking a period also adds its dataKey to an already-customized visibleKeys allowlist', () => { - // visibleKeys, once customized, acts as an allowlist (getVisibleHeaders - // filters out anything not in it) - the new period column's dataKey - // must be added too, or it would never actually render. - const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - columnConfig: { visibleKeys: ['name'] }, - }) - openPicker() - fireEvent.click(screen.getByLabelText('January 2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_TABLE_COLUMN_CONFIG_SET, - layerId: 'layer1', - config: { - visibleKeys: ['name', 'period_202301_rawValue'], - pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202301'], - }, - }) - }) - - test('unchecking an already-added period dispatches extraPeriodIds without it', () => { - const { store } = renderColumnPicker({ - renderingStrategy: RENDERING_STRATEGY_TIMELINE, - periods, - columnConfig: { extraPeriodIds: ['202301', '202302'] }, - }) - openPicker() - expect(screen.getByLabelText('January 2023')).toBeChecked() - fireEvent.click(screen.getByLabelText('January 2023')) - expect(store.getActions()).toContainEqual({ - type: DATA_TABLE_COLUMN_CONFIG_SET, - layerId: 'layer1', - config: { - visibleKeys: ['name', 'rawValue', 'legend'], - pinnedKeys: [], - orderedKeys: ['name', 'rawValue', 'legend'], - extraPeriodIds: ['202302'], + orderedKeys: [ + 'name', + 'rawValue', + 'legend', + 'period_202301_rawValue', + ], }, }) }) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index ea143ce676..c7414136db 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -201,6 +201,24 @@ describe('FilterInput multi-select path (no optionSetId)', () => { ).toBeInTheDocument() }) + test('renders Icon column options as a thumbnail plus filename, not the raw URL', () => { + renderFilterInput({ + dataKey: 'iconUrl', + name: 'Icon', + options: [{ value: 'https://server/api/icons/mapMarker024.png' }], + }) + openPopover('Icon') + const checkbox = screen.getByLabelText('mapMarker024.png') + expect(checkbox).toBeInTheDocument() + expect( + screen.queryByLabelText('https://server/api/icons/mapMarker024.png') + ).not.toBeInTheDocument() + expect(checkbox.closest('label').querySelector('img')).toHaveAttribute( + 'src', + 'https://server/api/icons/mapMarker024.png' + ) + }) + test('formats numeric column options with the system digit group separator', () => { renderFilterInput({ dataKey: 'value', diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index f231acd0b6..f55ca6520b 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -261,11 +261,11 @@ describe('useTableData headers', () => { { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, - { name: 'Legend', dataKey: 'legend', type: 'string' }, - { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, { name: 'Parent', dataKey: 'parentName', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, + { name: 'Legend', dataKey: 'legend', type: 'string' }, + { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Color', dataKey: 'color', @@ -279,22 +279,28 @@ describe('useTableData headers', () => { { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, { value: 106.3, dataKey: 'rawValue' }, - { value: 'Great', dataKey: 'legend' }, - { value: '90 – 120', dataKey: 'range' }, { value: 4, dataKey: 'level' }, { value: 'Badjia', dataKey: 'parentName' }, { value: 'Point', dataKey: 'type' }, + { value: 'Great', dataKey: 'legend' }, + { value: '90 – 120', dataKey: 'range' }, { value: '#FFFFB2', dataKey: 'color' }, ]) expect(isLoading).toBe(false) }) test('gets current-period Value/Legend/Range/Color for a timeline thematic layer', () => { - const store = { aggregations: {} } + // The active timeline period is Map.jsx's own local UI state, synced + // into state.ui.activeTimelinePeriod (not part of the layer config). + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } const layer = { layer: 'thematic', renderingStrategy: 'TIMELINE', - externalPeriod: { id: '202302', name: 'February 2023' }, valuesByPeriod: { 202301: { 'ou-1': { value: 100, color: '#aaaaaa', legend: 'Low' }, @@ -337,11 +343,11 @@ describe('useTableData headers', () => { { name: 'Name', dataKey: 'name' }, { name: 'Id', dataKey: 'id' }, { name: 'Value (February 2023)', dataKey: 'rawValue' }, - { name: 'Legend (February 2023)', dataKey: 'legend' }, - { name: 'Range (February 2023)', dataKey: 'range' }, { name: 'Level', dataKey: 'level' }, { name: 'Parent', dataKey: 'parentName' }, { name: 'Type', dataKey: 'type' }, + { name: 'Legend (February 2023)', dataKey: 'legend' }, + { name: 'Range (February 2023)', dataKey: 'range' }, { name: 'Color (February 2023)', dataKey: 'color' }, ]) expect(rows[0]).toEqual( @@ -356,17 +362,23 @@ describe('useTableData headers', () => { ) }) - test('adds a raw-value-only extra period column for a timeline thematic layer', () => { - const store = { aggregations: {} } + test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { + // Period columns exist for every period regardless of any saved + // config - they're just hidden by default (defaultHidden), same + // mechanism as any other column, controlled via the column picker. + const store = { + aggregations: {}, + ui: { + activeTimelinePeriod: { id: '202302', name: 'February 2023' }, + }, + } const layer = { layer: 'thematic', renderingStrategy: 'TIMELINE', - externalPeriod: { id: '202302', name: 'February 2023' }, periods: [ { id: '202301', name: 'January 2023' }, { id: '202302', name: 'February 2023' }, ], - dataTableColumnConfig: { extraPeriodIds: ['202301'] }, valuesByPeriod: { 202301: { 'ou-1': { value: 100 } }, 202302: { 'ou-1': { value: 200 } }, @@ -396,10 +408,16 @@ describe('useTableData headers', () => { } ) const { headers, rows } = result.current + // The active period (February 2023) is the Value/Legend/Range/Color + // columns, not a separate period_* column. + expect(headers).not.toContainEqual( + expect.objectContaining({ dataKey: 'period_202302_rawValue' }) + ) expect(headers).toContainEqual({ name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', type: 'number', + defaultHidden: true, }) expect(rows[0]).toContainEqual( expect.objectContaining({ @@ -409,19 +427,14 @@ describe('useTableData headers', () => { ) }) - test('split-by-period thematic layer has no default current-period column, only extras', () => { + test('split-by-period thematic layer has no default current-period column, only defaultHidden period columns', () => { const store = { aggregations: {} } const layer = { layer: 'thematic', renderingStrategy: 'SPLIT_BY_PERIOD', - externalPeriod: { id: '202302', name: 'February 2023' }, periods: [{ id: '202301', name: 'January 2023' }], - dataTableColumnConfig: { extraPeriodIds: ['202301'] }, valuesByPeriod: { 202301: { 'ou-1': { value: 100 } }, - 202302: { - 'ou-1': { value: 200, color: '#bbbbbb', legend: 'High' }, - }, }, dataFilters: null, data: [ @@ -457,6 +470,7 @@ describe('useTableData headers', () => { { name: 'Value (January 2023)', dataKey: 'period_202301_rawValue', + defaultHidden: true, }, ]) expect(rows[0]).not.toContainEqual( diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 2033bf59c7..d54280db7f 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -23,13 +23,12 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' -import { RENDERING_STRATEGY_SINGLE } from '../../../constants/layers.js' import { + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCount, - getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, - togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../../../util/tableColumns.js' @@ -40,13 +39,7 @@ import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 -const ColumnPickerControl = ({ - layerId, - allHeaders, - columnConfig, - renderingStrategy, - periods, -}) => { +const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -76,20 +69,17 @@ const ColumnPickerControl = ({ const headers = allHeaders ?? [] const visibleKeys = - columnConfig?.visibleKeys ?? headers.map((h) => h.dataKey) + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) const pinnedKeys = columnConfig?.pinnedKeys ?? [] const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - const orderedHeaders = getVisibleHeaders(headers, { + const orderedHeaders = getOrderedHeaders(headers, { orderedKeys, pinnedKeys, }) const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) - const extraPeriodIds = columnConfig?.extraPeriodIds ?? [] - const isMultiPeriodThematic = - renderingStrategy && renderingStrategy !== RENDERING_STRATEGY_SINGLE const updateConfig = (partial) => dispatch( @@ -97,7 +87,6 @@ const ColumnPickerControl = ({ visibleKeys, pinnedKeys, orderedKeys, - extraPeriodIds, ...partial, }) ) @@ -124,25 +113,6 @@ const ColumnPickerControl = ({ const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) - const onTogglePeriodId = (periodId) => { - const isAdding = !extraPeriodIds.includes(periodId) - updateConfig({ - extraPeriodIds: togglePeriodId(extraPeriodIds, periodId), - // A newly-added period's column only has a header once - // useTableData sees the updated extraPeriodIds - but - // visibleKeys, once customized, is an allowlist, so its new - // dataKey needs adding here too or the column would never - // actually render. - ...(isAdding && - columnConfig?.visibleKeys && { - visibleKeys: [ - ...visibleKeys, - `period_${periodId}_rawValue`, - ], - }), - }) - } - const filteredHeaders = orderedHeaders.filter((h) => h.name.toLowerCase().includes(search.trim().toLowerCase()) ) @@ -318,39 +288,6 @@ const ColumnPickerControl = ({ document.body )} - {isMultiPeriodThematic && periods?.length > 0 && ( -
-

- {i18n.t('Add period columns')} -

-
- {periods.map(({ id, name }) => ( - - ))} -
-
- )} )} @@ -367,18 +304,10 @@ ColumnPickerControl.propTypes = { }) ), columnConfig: PropTypes.shape({ - extraPeriodIds: PropTypes.arrayOf(PropTypes.string), orderedKeys: PropTypes.arrayOf(PropTypes.string), pinnedKeys: PropTypes.arrayOf(PropTypes.string), visibleKeys: PropTypes.arrayOf(PropTypes.string), }), - periods: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - }) - ), - renderingStrategy: PropTypes.string, } export default ColumnPickerControl diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css index 6c56ce200e..fa974e6fd3 100644 --- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -137,50 +137,3 @@ background-color: var(--colors-white); box-shadow: var(--elevations-popover); } - -.periodsSection { - margin-top: var(--spacers-dp8); - padding-top: var(--spacers-dp8); - border-top: 1px solid var(--colors-grey300); -} - -.periodsSectionLabel { - margin: 0 0 var(--spacers-dp4); - font-size: 11px; - font-weight: 600; - color: var(--colors-grey700); -} - -.periodsList { - display: flex; - flex-direction: column; - max-height: 150px; - overflow-y: auto; -} - -.periodRow { - display: flex; - align-items: center; - gap: var(--spacers-dp4); - padding: var(--spacers-dp2) var(--spacers-dp4); - border-radius: 3px; - cursor: pointer; -} - -.periodRow:hover { - background: var(--colors-grey100); -} - -.periodRow input[type='checkbox'] { - flex-shrink: 0; - accent-color: var(--colors-teal600); -} - -.periodRowLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-size: 12px; -} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 336b3c496a..83a175c0ee 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -104,6 +104,23 @@ font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } +.iconOption { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconOptionThumbnail { + display: block; + flex: none; + width: 16px; + height: 16px; + object-fit: contain; +} + .denseCheckbox { margin: 0; padding: 4px 0; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 9e4dc65e60..d07c55da7d 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -114,21 +114,56 @@ const defaultFieldsMap = () => ({ }, }) +// Canonical trailing order for classification/styling columns - shared +// across every layer type that has any subset of them, so switching +// between layer types never reshuffles where these appear relative to +// each other (e.g. Group always comes before Color, Color always comes +// before Icon, regardless of which layer type is showing them). +const getStyleHeaders = ({ + hasLegend, + hasRange, + hasGroup, + hasColor, + hasIcon, +}) => { + const headers = [] + if (hasLegend) { + headers.push(defaultFieldsMap()[LEGEND]) + } + if (hasRange) { + headers.push(defaultFieldsMap()[RANGE]) + } + if (hasGroup) { + headers.push(defaultFieldsMap()[GROUP]) + } + if (hasColor) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (hasIcon) { + headers.push(defaultFieldsMap()[ICON]) + } + return headers +} + const getThematicHeaders = () => - [NAME, ID, VALUE, LEGEND, RANGE, LEVEL, PARENT_NAME, TYPE, COLOR].map( - (field) => defaultFieldsMap()[field] - ) + [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) // Timeline gets the standard Value/Legend/Range/Color columns, relabeled // with the active period's name (updates live as the timeline slider // moves). Split-by-period has no single "current" period to privilege, so // it only gets the base org unit columns - same shape as getOrgUnitHeaders. -// Both strategies can add extra, raw-value-only period columns via the -// column picker's "Periods" section. +// Every other available period (all of them, for split - every one but the +// active one, for timeline, since that one's already the Value/Legend/ +// Range/Color columns above) gets its own raw-value-only column too, +// hidden by default (defaultHidden) so the table isn't cluttered with +// every period until the user turns one on from the column picker. const getMultiPeriodThematicHeaders = ({ isTimelineThematic, externalPeriod, - extraPeriodIds, periods, }) => { const headers = isTimelineThematic @@ -144,13 +179,16 @@ const getMultiPeriodThematicHeaders = ({ ) : getOrgUnitHeaders() - extraPeriodIds.forEach((periodId) => { - const periodName = - periods?.find((p) => p.id === periodId)?.name ?? periodId + const otherPeriods = isTimelineThematic + ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) + : periods ?? [] + + otherPeriods.forEach((period) => { headers.push({ - name: i18n.t('Value ({{period}})', { period: periodName }), - dataKey: `period_${periodId}_rawValue`, + name: i18n.t('Value ({{period}})', { period: period.name }), + dataKey: `period_${period.id}_rawValue`, type: TYPE_NUMBER, + defaultHidden: true, }) }) @@ -183,40 +221,32 @@ const getEventHeaders = ({ })) customFields.push(defaultFieldsMap()[TYPE]) - - if (styleDataItem) { - customFields.push( - defaultFieldsMap()[LEGEND], - defaultFieldsMap()[RANGE], - defaultFieldsMap()[COLOR] - ) - } + customFields.push( + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) return fields.concat(customFields) } -// Facility/org unit layers only get Color/Icon/Group columns when the +// Facility/org unit layers only get Group/Color/Icon columns when the // current group-set styling actually produced them - style type (and // whether every org unit matched a group) isn't known up front, so this // checks the resolved row data rather than re-deriving that logic here. -const getGroupSetStyleHeaders = (data) => { - const headers = [] - if (data?.some((d) => d.color != null)) { - headers.push(defaultFieldsMap()[COLOR]) - } - if (data?.some((d) => d.iconUrl != null)) { - headers.push(defaultFieldsMap()[ICON]) - } - if (data?.some((d) => d.group != null)) { - headers.push(defaultFieldsMap()[GROUP]) - } - return headers -} +const getOrgUnitStyleHeaders = (data) => + getStyleHeaders({ + hasGroup: data?.some((d) => d.group != null), + hasColor: data?.some((d) => d.color != null), + hasIcon: data?.some((d) => d.iconUrl != null), + }) const getOrgUnitHeaders = (data) => [NAME, ID, LEVEL, PARENT_NAME, TYPE] .map((field) => defaultFieldsMap()[field]) - .concat(getGroupSetStyleHeaders(data)) + .concat(getOrgUnitStyleHeaders(data)) // Unlike getEventHeaders's layerHeaders (raw analytics response shape, // name=uid/column=display), trackedEntityLoader.js already builds its @@ -235,7 +265,7 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { : TYPE_STRING, })) - customFields.push(defaultFieldsMap()[COLOR]) + customFields.push(...getStyleHeaders({ hasColor: true })) return fields.concat(customFields) } @@ -243,7 +273,7 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const getFacilityHeaders = (data) => [NAME, ID, TYPE] .map((field) => defaultFieldsMap()[field]) - .concat(getGroupSetStyleHeaders(data)) + .concat(getOrgUnitStyleHeaders(data)) const toTitleCase = (str) => str.replace( @@ -309,6 +339,13 @@ export const useTableData = ({ }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS + // The timeline's active period is Map.jsx's own local UI state, not + // part of the layer config stored in Redux - it's synced into + // state.ui separately (see Map.jsx/MapContainer.jsx) so the data + // table, a sibling of the map, can read the same "current period". + const externalPeriod = useSelector( + (state) => state.ui?.activeTimelinePeriod + ) const errorCode = useRef(null) @@ -325,9 +362,7 @@ export const useTableData = ({ serverCluster, renderingStrategy, valuesByPeriod, - externalPeriod, periods, - dataTableColumnConfig, legendDecimalPlaces, } = layer || EMPTY_LAYER @@ -338,10 +373,6 @@ export const useTableData = ({ const isTimelineThematic = isMultiPeriodThematic && renderingStrategy === RENDERING_STRATEGY_TIMELINE - const extraPeriodIds = useMemo( - () => dataTableColumnConfig?.extraPeriodIds ?? [], - [dataTableColumnConfig] - ) const isStyledEvent = layerType === EVENT_LAYER && !!styleDataItem // Only depend on mapBounds while the toggle is on, so panning/zooming @@ -413,10 +444,16 @@ export const useTableData = ({ const currentPeriodItem = isTimelineThematic ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] : null - const extraPeriodValues = {} - extraPeriodIds.forEach((pid) => { - extraPeriodValues[`period_${pid}_rawValue`] = - valuesByPeriod?.[pid]?.[orgUnitId]?.value ?? null + const otherPeriodValues = {} + ;(periods ?? []).forEach((period) => { + if ( + isTimelineThematic && + period.id === externalPeriod?.id + ) { + return + } + otherPeriodValues[`period_${period.id}_rawValue`] = + valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null }) return { @@ -427,7 +464,7 @@ export const useTableData = ({ legend: currentPeriodItem.legend, range: currentPeriodItem.range, }), - ...extraPeriodValues, + ...otherPeriodValues, ...aggregations[d.id], index, } @@ -445,7 +482,7 @@ export const useTableData = ({ isTimelineThematic, valuesByPeriod, externalPeriod, - extraPeriodIds, + periods, isStyledEvent, legend, keyAnalysisDigitGroupSeparator, @@ -464,7 +501,6 @@ export const useTableData = ({ ? getMultiPeriodThematicHeaders({ isTimelineThematic, externalPeriod, - extraPeriodIds, periods, }) : getThematicHeaders() @@ -527,7 +563,6 @@ export const useTableData = ({ isMultiPeriodThematic, isTimelineThematic, externalPeriod, - extraPeriodIds, periods, ]) diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 3358c2f28f..8269043eb2 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -58,6 +58,7 @@ class Map extends Component { resizeCount: PropTypes.number, selection: PropTypes.object, selectionFilter: PropTypes.array, + setActiveTimelinePeriod: PropTypes.func, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, @@ -191,6 +192,7 @@ class Map extends Component { coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, + setActiveTimelinePeriod, setAggregations, setFeatureProfile, resizeCount, @@ -215,9 +217,10 @@ class Map extends Component { periodId={period.id} period={period} periods={timelineOverlay?.periods} - onChange={(period) => + onChange={(period) => { this.setState({ period }) - } + setActiveTimelinePeriod?.(period) + }} resizeCount={resizeCount} /> @@ -322,6 +325,7 @@ class Map extends Component { if (initialPeriod) { this.setState({ period: initialPeriod }) + this.props.setActiveTimelinePeriod?.(initialPeriod) } } } diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 6db6795fed..3e0b018baf 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types' import React, { useCallback } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setAggregations } from '../../actions/aggregations.js' +import { setActiveTimelinePeriod } from '../../actions/dataTable.js' import { highlightFeature, setFeatureProfile, @@ -64,6 +65,9 @@ const MapContainer = ({ resizeCount, setMap }) => { closeCoordinatePopup={() => dispatch(closeCoordinatePopup())} setAggregations={(data) => dispatch(setAggregations(data))} setFeatureProfile={(val) => dispatch(setFeatureProfile(val))} + setActiveTimelinePeriod={(period) => + dispatch(setActiveTimelinePeriod(period)) + } resizeCount={resizeCount} setMapObject={setMap} layersSorting={layersSorting} diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index ea24c6b490..96ed39d896 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -46,6 +46,7 @@ export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' export const DATA_TABLE_COLUMN_CONFIG_SET = 'DATA_TABLE_COLUMN_CONFIG_SET' +export const ACTIVE_TIMELINE_PERIOD_SET = 'ACTIVE_TIMELINE_PERIOD_SET' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js index e3e53fb768..b0025f4354 100644 --- a/src/reducers/__tests__/ui.spec.js +++ b/src/reducers/__tests__/ui.spec.js @@ -81,3 +81,19 @@ describe('ui reducer — lastClickedFeature', () => { expect(state.lastClickedFeature).toBe(null) }) }) + +describe('ui reducer — activeTimelinePeriod', () => { + it('defaults to null', () => { + expect(ui(undefined, {}).activeTimelinePeriod).toBe(null) + }) + + it('sets the active timeline period on ACTIVE_TIMELINE_PERIOD_SET', () => { + const period = { id: '202301', name: 'January 2023' } + const state = ui(undefined, { + type: types.ACTIVE_TIMELINE_PERIOD_SET, + period, + }) + + expect(state.activeTimelinePeriod).toEqual(period) + }) +}) diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 45fb37be3c..5789ed6e57 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -14,6 +14,7 @@ const defaultState = { selectionFilter: [], highlightColor: null, lastClickedFeature: null, + activeTimelinePeriod: null, } const ui = (state = defaultState, action) => { @@ -121,6 +122,12 @@ const ui = (state = defaultState, action) => { lastClickedFeature: action.payload, } + case types.ACTIVE_TIMELINE_PERIOD_SET: + return { + ...state, + activeTimelinePeriod: action.period, + } + default: return state } diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index b76c71825b..c061aeeadc 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,11 +1,12 @@ import { + getDefaultVisibleKeys, + getOrderedHeaders, getPinnedCellProps, getPinnedCount, getPinnedLeftOffsets, getVisibleHeaders, isPinnedGroupEnd, reverseVisibleKeys, - togglePeriodId, togglePinnedKey, toggleVisibleKey, } from '../tableColumns.js' @@ -17,6 +18,38 @@ const headers = [ { name: 'Legend', dataKey: 'legend' }, ] +describe('getOrderedHeaders', () => { + it('returns every header, ordered/pinned but never filtered by visibility - even defaultHidden ones', () => { + const withHiddenColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getOrderedHeaders(withHiddenColumn, {}) + expect(result).toEqual(withHiddenColumn) + }) + + it('still applies ordering and pinning', () => { + const result = getOrderedHeaders(headers, { + orderedKeys: ['legend', 'name', 'id', 'rawValue'], + pinnedKeys: ['rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'rawValue', + 'legend', + 'name', + 'id', + ]) + }) + + it('passes through a null/undefined headers list', () => { + expect(getOrderedHeaders(null)).toBe(null) + }) +}) + describe('getVisibleHeaders', () => { it('returns all headers unchanged when there is no saved config', () => { expect(getVisibleHeaders(headers, null)).toEqual(headers) @@ -125,6 +158,42 @@ describe('getVisibleHeaders', () => { 'name', ]) }) + + it('excludes defaultHidden headers when there is no saved config yet', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, null) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', + ]) + }) + + it('shows a defaultHidden header once explicitly added to visibleKeys', () => { + const withPeriodColumn = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + const result = getVisibleHeaders(withPeriodColumn, { + visibleKeys: ['name', 'period_202301_rawValue'], + }) + expect(result.map((h) => h.dataKey)).toEqual([ + 'name', + 'period_202301_rawValue', + ]) + }) }) describe('getPinnedLeftOffsets', () => { @@ -303,17 +372,30 @@ describe('getPinnedCellProps', () => { }) }) -describe('togglePeriodId', () => { - it('adds a period id when it is not yet added', () => { - expect(togglePeriodId(['202301'], '202302')).toEqual([ - '202301', - '202302', +describe('getDefaultVisibleKeys', () => { + it('includes every header dataKey when none are marked defaultHidden', () => { + expect(getDefaultVisibleKeys(headers)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', ]) }) - it('removes a period id when it is already added', () => { - expect(togglePeriodId(['202301', '202302'], '202301')).toEqual([ - '202302', + it('excludes headers marked defaultHidden', () => { + const withHidden = [ + ...headers, + { + name: 'Value (Jan 2023)', + dataKey: 'period_202301_rawValue', + defaultHidden: true, + }, + ] + expect(getDefaultVisibleKeys(withHidden)).toEqual([ + 'name', + 'id', + 'rawValue', + 'legend', ]) }) }) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index b34c46350b..76cf355527 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -50,6 +50,24 @@ describe('compareFieldValues', () => { ).toBe(0) }) + it('sorts null values to the end too, without throwing (e.g. a period column with no data for some rows)', () => { + expect( + compareFieldValues(null, 5, { sortDirection: 'asc' }) + ).toBeGreaterThan(0) + expect( + compareFieldValues(5, null, { sortDirection: 'desc' }) + ).toBeLessThan(0) + }) + + it('treats null and undefined as equally "no value"', () => { + expect( + compareFieldValues(null, undefined, { sortDirection: 'asc' }) + ).toBe(0) + expect( + compareFieldValues(undefined, null, { sortDirection: 'asc' }) + ).toBe(0) + }) + it('delegates to compareRangeValues for the Range column', () => { expect( compareFieldValues('5-10', '1-3', { diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 8980ae0a0e..496748c82e 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -5,13 +5,23 @@ const getOrderIndex = (dataKey, orderedKeys) => { return index === -1 ? orderedKeys.length : index } -export const getVisibleHeaders = (headers, columnConfig) => { +// A header can opt out of the "everything visible by default" rule (e.g. +// period columns, which exist for every available period but would clutter +// the table if all shown before the user picks any) - used both here and +// by ColumnPickerControl, so the table and the picker's checkboxes always +// agree on what "not yet customized" means. +export const getDefaultVisibleKeys = (headers) => + headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) + +// Ordering + pinning only, deliberately never filtered by visibility - used +// by the column picker, which needs a row for every header regardless of +// whether it's currently shown, and by getVisibleHeaders below. +export const getOrderedHeaders = (headers, config) => { if (!headers) { return headers } - const { visibleKeys, orderedKeys } = columnConfig ?? {} - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const { orderedKeys, pinnedKeys } = config ?? {} let result = orderedKeys ? [...headers].sort( @@ -21,11 +31,7 @@ export const getVisibleHeaders = (headers, columnConfig) => { ) : headers - if (visibleKeys) { - result = result.filter((h) => visibleKeys.includes(h.dataKey)) - } - - if (pinnedKeys.length) { + if (pinnedKeys?.length) { const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) result = [...pinned, ...rest] @@ -34,6 +40,19 @@ export const getVisibleHeaders = (headers, columnConfig) => { return result } +export const getVisibleHeaders = (headers, columnConfig) => { + if (!headers) { + return headers + } + + const visibleKeys = + columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) + + return getOrderedHeaders(headers, columnConfig).filter((h) => + visibleKeys.includes(h.dataKey) + ) +} + export const getPinnedCount = (orderedHeaders, pinnedKeys) => { if (!orderedHeaders?.length || !pinnedKeys?.length) { return 0 @@ -68,11 +87,6 @@ export const reverseVisibleKeys = (headers, visibleKeys) => .filter((h) => !visibleKeys.includes(h.dataKey)) .map((h) => h.dataKey) -export const togglePeriodId = (extraPeriodIds, periodId) => - extraPeriodIds.includes(periodId) - ? extraPeriodIds.filter((id) => id !== periodId) - : [...extraPeriodIds, periodId] - // @dhis2/ui requires `width` whenever `fixed` is passed export const getPinnedCellProps = ( dataKey, diff --git a/src/util/tableSort.js b/src/util/tableSort.js index 6af6052b89..d101d6caa1 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -56,19 +56,22 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { return sortDirection === SORT_ASCENDING ? aEnd - bEnd : bEnd - aEnd } +const isNoValue = (val) => val === undefined || val === null + export const compareFieldValues = ( aVal, bVal, { sortField, sortDirection } ) => { - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { + // All missing values (undefined, or null - e.g. a period column with no + // data for a given org unit) should be sorted to the end + if (isNoValue(aVal) && isNoValue(bVal)) { return 0 } - if (aVal === undefined) { + if (isNoValue(aVal)) { return 1 } - if (bVal === undefined) { + if (isNoValue(bVal)) { return -1 } if (typeof aVal === 'number') { From 30e0fb03bc55ef4fb7f5d0f7b3c8658e5514d83c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 19 Jul 2026 12:31:31 +0200 Subject: [PATCH 13/47] chore: sonarqube issues --- src/components/datatable/useTableData.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d07c55da7d..cb214ef58d 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -220,8 +220,8 @@ const getEventHeaders = ({ optionSet: optionSet || null, })) - customFields.push(defaultFieldsMap()[TYPE]) customFields.push( + defaultFieldsMap()[TYPE], ...getStyleHeaders({ hasLegend: !!styleDataItem, hasRange: !!styleDataItem, From 26e2122573172e98153daaa46996219731450b6f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 20 Jul 2026 19:36:35 +0200 Subject: [PATCH 14/47] chore: fix cypress test --- cypress/integration/dataTable.cy.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 45eb84debd..6e6bfc048f 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -358,7 +358,7 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 5, expectedContent: '' }) + checkTableCell({ row: 0, column: 8, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -367,12 +367,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: 5, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 8, expectedContent: '' }) }) }) From a3ac54fef4774e2e9ca8ba5999001f87d03f5ee1 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 21 Jul 2026 13:22:22 +0200 Subject: [PATCH 15/47] fix: datatable performance optimisation --- package.json | 2 +- src/components/datatable/BottomPanel.jsx | 24 +- src/components/datatable/DataTable.jsx | 340 +++++++++--------- src/components/datatable/FilterInput.jsx | 103 ++++-- .../datatable/TableVirtuosoComponents.jsx | 22 +- .../controls/ColumnPickerControl.jsx | 48 ++- .../datatable/styles/BottomPanel.module.css | 1 + .../datatable/styles/DataTable.module.css | 2 +- src/components/datatable/useTableData.js | 121 ++++--- src/util/tableColumns.js | 29 +- yarn.lock | 4 +- 11 files changed, 392 insertions(+), 304 deletions(-) diff --git a/package.json b/package.json index 05dd149b49..42f07ecd43 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc", "@dhis2/ui": "^10.17.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 8ff0c7bb70..cd13789a94 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -238,19 +238,17 @@ const BottomPanel = () => { - {!isCollapsed && ( -
- - - -
- )} +
+ + + +
) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index bb85c7a3b9..79ebc00b6a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -57,6 +57,9 @@ import { useColumnWidths } from './useColumnWidths.js' import { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' +const TABLE_STYLE = { height: '100%', width: '100%' } +const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 } + const Table = ({ availableWidth, onCountChange, @@ -97,11 +100,18 @@ const Table = ({ [sortField, sortDirection] ) + // Read via ref rather than a dependency, so this callback (and anything + // memoized on it, e.g. tableContext) stays stable across hovers instead + // of getting a new identity on every single mouse-enter + const featureRef = useRef(feature) + featureRef.current = feature + const setFeatureHighlight = useCallback( (row) => { const id = getRowId(row) + const currentFeature = featureRef.current - if (!id || !feature || id !== feature.id) { + if (!id || !currentFeature || id !== currentFeature.id) { dispatch( highlightFeature( id @@ -115,7 +125,7 @@ const Table = ({ ) } }, - [feature, dispatch, layer.id] + [dispatch, layer.id] ) const clearFeatureHighlight = useCallback( (event) => { @@ -338,6 +348,168 @@ const Table = ({ layerId: layer.id, }) + const computeItemKey = useCallback( + (index, row) => getRowId(row) ?? index, + [] + ) + + const fixedHeaderContent = useCallback( + () => ( + + + dispatch(setSelectionFilter(next)) + } + /> + } + > +
+ + + + + + + + + +
+
+ {visibleHeaders.map( + ({ name, dataKey, type, optionSet }, index) => { + const { fixed, left, isLastPinned } = + getPinnedCellProps(dataKey, index, { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + }) + return ( + + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + + {name} + + + + + + ) + } + )} +
+ ), + [ + isCheckboxColumnPinned, + selectionFilter, + dispatch, + allRowIds, + onReverseSelection, + sortData, + sortField, + sortDirection, + visibleHeaders, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + columnOptions, + isAllSelected, + onToggleSelectAll, + headerRowRef, + ] + ) + if (error) { return

{error}

} @@ -348,163 +520,11 @@ const Table = ({ ref={virtuosoRef} context={tableContext} components={TableComponents} - style={{ - height: '100%', - width: '100%', - }} + style={TABLE_STYLE} data={rows} - computeItemKey={(index, row) => getRowId(row) ?? index} - increaseViewportBy={{ top: 400, bottom: 400 }} - fixedHeaderContent={() => ( - - - dispatch(setSelectionFilter(next)) - } - /> - } - > -
- - - - - - - - - -
-
- {visibleHeaders.map( - ({ name, dataKey, type, optionSet }, index) => { - const { fixed, left, isLastPinned } = - getPinnedCellProps(dataKey, index, { - pinnedLeftOffsets, - pinnedColumnCount, - columnWidths, - }) - return ( - - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - - {name} - - - - - - ) - } - )} -
- )} + computeItemKey={computeItemKey} + increaseViewportBy={VIEWPORT_OVERSCAN} + fixedHeaderContent={fixedHeaderContent} itemContent={(_, row) => { const rowId = getRowId(row) const isSelected = !!rowId && selectedIdSet.has(rowId) @@ -615,10 +635,10 @@ const Table = ({ }} /> {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( - +
- + {loadingReason && ( {loadingReason} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 640c1c73b7..5fa0633ca3 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -2,7 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -56,7 +56,7 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -const SearchableFilterPopover = ({ +const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ dataKey, name, layerId, @@ -65,7 +65,7 @@ const SearchableFilterPopover = ({ resolveLabel, type, allowCustomFilter = true, -}) => { +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const listRef = useRef(null) @@ -123,13 +123,18 @@ const SearchableFilterPopover = ({ resolveLabel(value) ) - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE + const hasNotSetOption = useMemo( + () => options.some(({ value }) => value === SENTINEL_NO_VALUE), + [options] ) - const realOptions = options.filter( - ({ value }) => value !== SENTINEL_NO_VALUE + const realOptions = useMemo( + () => options.filter(({ value }) => value !== SENTINEL_NO_VALUE), + [options] + ) + const realValues = useMemo( + () => realOptions.map((o) => o.value), + [realOptions] ) - const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) const popoverWidth = useMemo(() => { @@ -145,7 +150,10 @@ const SearchableFilterPopover = ({ const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) - const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) + const invertibleValues = useMemo( + () => getInvertibleValues(hasNotSetOption, realValues), + [hasNotSetOption, realValues] + ) const onToggleRealValue = (value) => applyValues(toggleRealValue(selected, value, realValues)) @@ -155,15 +163,24 @@ const SearchableFilterPopover = ({ const trimmedSearch = searchText.trim() const normalizedSearch = trimmedSearch.toLowerCase() - const filteredOptions = getFilteredOptions({ - realOptions, - trimmedSearch, - normalizedSearch, - type, - resolveLabel, - }) - const hasExactMatch = filteredOptions.some( - ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch + const filteredOptions = useMemo( + () => + getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }), + [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] + ) + const hasExactMatch = useMemo( + () => + filteredOptions.some( + ({ value }) => + resolveLabel(value).toLowerCase() === normalizedSearch + ), + [filteredOptions, resolveLabel, normalizedSearch] ) const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' && !hasExactMatch @@ -451,7 +468,7 @@ const SearchableFilterPopover = ({ )}
) -} +}) SearchableFilterPopover.propTypes = { dataKey: PropTypes.string.isRequired, @@ -474,14 +491,20 @@ const PlainSearchableFilter = (props) => { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const resolveLabel = (value) => { - if (value === SENTINEL_NO_VALUE) { - return i18n.t('No value') - } - return type === 'number' - ? formatWithSeparator(Number(value), keyAnalysisDigitGroupSeparator) - : value - } + const resolveLabel = useCallback( + (value) => { + if (value === SENTINEL_NO_VALUE) { + return i18n.t('No value') + } + return type === 'number' + ? formatWithSeparator( + Number(value), + keyAnalysisDigitGroupSeparator + ) + : value + }, + [type, keyAnalysisDigitGroupSeparator] + ) return } @@ -492,10 +515,18 @@ PlainSearchableFilter.propTypes = { const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { const { optionSet } = useOptionSet(optionSetId) - const resolveLabel = (value) => - value === SENTINEL_NO_VALUE - ? i18n.t('No value') - : optionSet?.options.find((o) => o.code === value)?.name ?? value + const optionByCode = useMemo(() => { + const map = new Map() + optionSet?.options.forEach((o) => map.set(o.code, o)) + return map + }, [optionSet]) + const resolveLabel = useCallback( + (value) => + value === SENTINEL_NO_VALUE + ? i18n.t('No value') + : optionByCode.get(value)?.name ?? value, + [optionByCode] + ) return ( { +const FilterInput = React.memo(function FilterInput({ + type, + dataKey, + name, + options, + optionSetId, +}) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -545,7 +582,7 @@ const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { type={type} /> ) -} +}) FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index 46dd3b5964..fd58eae97b 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -23,15 +23,19 @@ DataTableWithVirtuosoContext.propTypes = { }), } -const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( - context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onContextMenu={(e) => context.onContextMenu(e, item)} - onClick={(e) => context.onRowClick(item, e)} - onDoubleClick={() => context.onRowDoubleClick(item)} - {...props} - /> +const DataTableRowWithVirtuosoContext = React.memo( + function DataTableRowWithVirtuosoContext({ context, item, ...props }) { + return ( + context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} + {...props} + /> + ) + } ) DataTableRowWithVirtuosoContext.propTypes = { diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index d54280db7f..11f555940e 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -19,7 +19,13 @@ import { import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react' +import React, { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' @@ -38,8 +44,14 @@ import styles from './styles/ColumnPickerControl.module.css' import ToolbarIconButton from './ToolbarIconButton.jsx' const DRAG_OVERLAY_Z_INDEX = 2100 +const EMPTY_HEADERS = [] +const EMPTY_KEYS = [] -const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { +const ColumnPickerControl = React.memo(function ColumnPickerControl({ + layerId, + allHeaders, + columnConfig, +}) { const dispatch = useDispatch() const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -66,20 +78,26 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { } }, []) - const headers = allHeaders ?? [] + const headers = allHeaders ?? EMPTY_HEADERS const visibleKeys = columnConfig?.visibleKeys ?? getDefaultVisibleKeys(headers) - const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const pinnedKeys = columnConfig?.pinnedKeys ?? EMPTY_KEYS const orderedKeys = columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) - const orderedHeaders = getOrderedHeaders(headers, { - orderedKeys, - pinnedKeys, - }) + const orderedHeaders = useMemo( + () => + isOpen + ? getOrderedHeaders(headers, { orderedKeys, pinnedKeys }) + : EMPTY_HEADERS, + [isOpen, headers, orderedKeys, pinnedKeys] + ) - const pinnedCount = getPinnedCount(orderedHeaders, pinnedKeys) + const pinnedCount = useMemo( + () => (isOpen ? getPinnedCount(orderedHeaders, pinnedKeys) : 0), + [isOpen, orderedHeaders, pinnedKeys] + ) const updateConfig = (partial) => dispatch( @@ -113,8 +131,14 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { const onResetToDefaults = () => dispatch(setDataTableColumnConfig(layerId, undefined)) - const filteredHeaders = orderedHeaders.filter((h) => - h.name.toLowerCase().includes(search.trim().toLowerCase()) + const filteredHeaders = useMemo( + () => + isOpen + ? orderedHeaders.filter((h) => + h.name.toLowerCase().includes(search.trim().toLowerCase()) + ) + : EMPTY_HEADERS, + [isOpen, orderedHeaders, search] ) const sensors = useSensors( @@ -293,7 +317,7 @@ const ColumnPickerControl = ({ layerId, allHeaders, columnConfig }) => { )} ) -} +}) ColumnPickerControl.propTypes = { layerId: PropTypes.string.isRequired, diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 083bac7ce9..5f23ec511b 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -17,6 +17,7 @@ .tableContainer { flex: 1; min-height: 0; + overflow: hidden; position: relative; } diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 41cb48405d..a083a8c5e8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -156,7 +156,7 @@ th.hovered { .loadingReason { font-size: 12px; - color: var(--colors-grey700); + color: var(--colors-white); } .noSupport { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index cb214ef58d..8afb7e574c 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,7 +1,11 @@ import i18n from '@dhis2/d2-i18n' -import { useMemo, useRef } from 'react' +import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' -import { SENTINEL_NO_VALUE, SORT_ASCENDING } from '../../constants/dataTable.js' +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -114,11 +118,6 @@ const defaultFieldsMap = () => ({ }, }) -// Canonical trailing order for classification/styling columns - shared -// across every layer type that has any subset of them, so switching -// between layer types never reshuffles where these appear relative to -// each other (e.g. Group always comes before Color, Color always comes -// before Icon, regardless of which layer type is showing them). const getStyleHeaders = ({ hasLegend, hasRange, @@ -152,15 +151,6 @@ const getThematicHeaders = () => getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) ) -// Timeline gets the standard Value/Legend/Range/Color columns, relabeled -// with the active period's name (updates live as the timeline slider -// moves). Split-by-period has no single "current" period to privilege, so -// it only gets the base org unit columns - same shape as getOrgUnitHeaders. -// Every other available period (all of them, for split - every one but the -// active one, for timeline, since that one's already the Value/Legend/ -// Range/Color columns above) gets its own raw-value-only column too, -// hidden by default (defaultHidden) so the table isn't cluttered with -// every period until the user turns one on from the column picker. const getMultiPeriodThematicHeaders = ({ isTimelineThematic, externalPeriod, @@ -232,26 +222,29 @@ const getEventHeaders = ({ return fields.concat(customFields) } -// Facility/org unit layers only get Group/Color/Icon columns when the -// current group-set styling actually produced them - style type (and -// whether every org unit matched a group) isn't known up front, so this -// checks the resolved row data rather than re-deriving that logic here. -const getOrgUnitStyleHeaders = (data) => - getStyleHeaders({ - hasGroup: data?.some((d) => d.group != null), - hasColor: data?.some((d) => d.color != null), - hasIcon: data?.some((d) => d.iconUrl != null), - }) +const getOrgUnitStyleHeaders = (data) => { + let hasGroup = false + let hasColor = false + let hasIcon = false + + for (const d of data ?? []) { + hasGroup ||= d.group != null + hasColor ||= d.color != null + hasIcon ||= d.iconUrl != null + + if (hasGroup && hasColor && hasIcon) { + break + } + } + + return getStyleHeaders({ hasGroup, hasColor, hasIcon }) +} const getOrgUnitHeaders = (data) => [NAME, ID, LEVEL, PARENT_NAME, TYPE] .map((field) => defaultFieldsMap()[field]) .concat(getOrgUnitStyleHeaders(data)) -// Unlike getEventHeaders's layerHeaders (raw analytics response shape, -// name=uid/column=display), trackedEntityLoader.js already builds its -// headers in the final {name, dataKey, valueType} shape - only the -// valueType -> table type classification needs doing here. const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const fields = [ID].map((field) => defaultFieldsMap()[field]) @@ -314,9 +307,6 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { .concat(customFields) } -// The synthetic per-geometry-type `color` property gets the same -// canonical, translated Color header every other layer type uses, -// rather than being treated as just another arbitrary uploaded field. const getGeoJsonUrlHeaders = (firstDataItem) => getGeojsonDisplayData(firstDataItem).map((header) => header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header @@ -339,10 +329,6 @@ export const useTableData = ({ }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS - // The timeline's active period is Map.jsx's own local UI state, not - // part of the layer config stored in Redux - it's synced into - // state.ui separately (see Map.jsx/MapContainer.jsx) so the data - // table, a sibling of the map, can read the same "current period". const externalPeriod = useSelector( (state) => state.ui?.activeTimelinePeriod ) @@ -378,6 +364,15 @@ export const useTableData = ({ // Only depend on mapBounds while the toggle is on, so panning/zooming // doesn't recompute dataWithAggregations below when it's off const boundsDependency = showOnlyFeaturesInView ? mapBounds : null + const selectedIdSetDependency = + sortField === SENTINEL_SELECTED_ROW || selectionFilter?.length + ? selectedIdSet + : null + const periodsDependency = isMultiPeriodThematic ? periods : null + const valuesByPeriodDependency = isMultiPeriodThematic + ? valuesByPeriod + : null + const externalPeriodDependency = isTimelineThematic ? externalPeriod : null const dataWithAggregations = useMemo(() => { errorCode.current = null @@ -411,9 +406,6 @@ export const useTableData = ({ const properties = d.properties || d if (isStyledEvent) { - // The event's own styling pass already classified this - // feature into legend.items[colorGroup] (color/radius) - - // Legend/Range are just a lookup, not new classification. const legendItem = legend?.items?.[properties.colorGroup] return { ...properties, @@ -469,6 +461,7 @@ export const useTableData = ({ index, } }) + // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ data, @@ -480,9 +473,9 @@ export const useTableData = ({ boundsDependency, isMultiPeriodThematic, isTimelineThematic, - valuesByPeriod, - externalPeriod, - periods, + valuesByPeriodDependency, + externalPeriodDependency, + periodsDependency, isStyledEvent, legend, keyAnalysisDigitGroupSeparator, @@ -551,6 +544,8 @@ export const useTableData = ({ return null } return headers + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ layerType, aggregationType, @@ -562,19 +557,21 @@ export const useTableData = ({ layerHeaders, isMultiPeriodThematic, isTimelineThematic, - externalPeriod, - periods, + externalPeriodDependency, + periodsDependency, ]) - const columnOptions = useMemo(() => { - if (!headers?.length || !dataWithAggregations?.length) { - return EMPTY_COLUMN_OPTIONS + // Expensive: scans every row once per column + const deferredDataForOptions = useDeferredValue(dataWithAggregations) + const columnDistinctValues = useMemo(() => { + if (!headers?.length || !deferredDataForOptions?.length) { + return null } const result = {} headers.forEach(({ dataKey, type }) => { const seen = new Set() - for (const item of dataWithAggregations) { + for (const item of deferredDataForOptions) { const val = item[dataKey] seen.add( val === undefined || val === null || val === '' @@ -584,9 +581,25 @@ export const useTableData = ({ } if (seen.size > 0) { + result[dataKey] = { values: Array.from(seen), type } + } + }) + + return result + }, [headers, deferredDataForOptions]) + + // Cheap: just re-orders each column's already-known distinct-value list + const columnOptions = useMemo(() => { + if (!columnDistinctValues) { + return EMPTY_COLUMN_OPTIONS + } + + const result = {} + Object.entries(columnDistinctValues).forEach( + ([dataKey, { values, type }]) => { const direction = dataKey === sortField ? sortDirection : SORT_ASCENDING - result[dataKey] = Array.from(seen) + result[dataKey] = [...values] .sort((a, b) => compareColumnOptionValues(a, b, { dataKey, @@ -596,10 +609,10 @@ export const useTableData = ({ ) .map((value) => ({ value })) } - }) + ) return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS - }, [headers, dataWithAggregations, sortField, sortDirection]) + }, [columnDistinctValues, sortField, sortDirection]) const rows = useMemo(() => { if (errorCode.current) { @@ -656,6 +669,8 @@ export const useTableData = ({ } }) ) + // *Dependency vars proxy their raw counterparts (see above) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ headers, dataWithAggregations, @@ -664,7 +679,7 @@ export const useTableData = ({ sortField, sortDirection, selectionFilter, - selectedIdSet, + selectedIdSetDependency, ]) // EE layers and event layers may be loading additional data diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index 496748c82e..fba5004a08 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,21 +1,8 @@ const CHECKBOX_COLUMN_WIDTH = 76 -const getOrderIndex = (dataKey, orderedKeys) => { - const index = orderedKeys.indexOf(dataKey) - return index === -1 ? orderedKeys.length : index -} - -// A header can opt out of the "everything visible by default" rule (e.g. -// period columns, which exist for every available period but would clutter -// the table if all shown before the user picks any) - used both here and -// by ColumnPickerControl, so the table and the picker's checkboxes always -// agree on what "not yet customized" means. export const getDefaultVisibleKeys = (headers) => headers.filter((h) => !h.defaultHidden).map((h) => h.dataKey) -// Ordering + pinning only, deliberately never filtered by visibility - used -// by the column picker, which needs a row for every header regardless of -// whether it's currently shown, and by getVisibleHeaders below. export const getOrderedHeaders = (headers, config) => { if (!headers) { return headers @@ -23,13 +10,15 @@ export const getOrderedHeaders = (headers, config) => { const { orderedKeys, pinnedKeys } = config ?? {} - let result = orderedKeys - ? [...headers].sort( - (a, b) => - getOrderIndex(a.dataKey, orderedKeys) - - getOrderIndex(b.dataKey, orderedKeys) - ) - : headers + let result = headers + if (orderedKeys) { + const orderIndex = new Map(orderedKeys.map((key, i) => [key, i])) + const getOrderIndex = (dataKey) => + orderIndex.get(dataKey) ?? orderedKeys.length + result = [...headers].sort( + (a, b) => getOrderIndex(a.dataKey) - getOrderIndex(b.dataKey) + ) + } if (pinnedKeys?.length) { const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) diff --git a/yarn.lock b/yarn.lock index 82fce786f0..c15648905d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2371,9 +2371,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc": version "4.4.3" - resolved "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6" + resolved "git+https://github.com/d2-ci/maps-gl.git#6758ac621ff7ed582ad458bb4c9f90900358cedc" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5" From 74207db2c26b816139c9751aa8ddded03d7654b2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 11:06:12 +0200 Subject: [PATCH 16/47] chore: PR clean-up --- src/components/datatable/BottomPanel.jsx | 29 +- src/components/datatable/DataTable.jsx | 66 +-- src/components/datatable/FilterInput.jsx | 69 ++- .../datatable/__tests__/FilterInput.spec.jsx | 1 + .../controls/ColumnPickerControl.jsx | 24 +- src/components/datatable/useTableData.js | 498 +++--------------- src/constants/dataTable.js | 7 + .../__tests__/trackedEntityLoader.spec.js | 12 +- src/loaders/thematicLoader.js | 1 + src/loaders/trackedEntityLoader.js | 40 +- src/util/__tests__/dataTable.spec.js | 118 +++++ src/util/__tests__/filterInput.spec.js | 59 +++ src/util/__tests__/tableColumns.spec.js | 97 ++++ src/util/__tests__/tableHeaders.spec.js | 234 ++++++++ src/util/__tests__/tableRows.spec.js | 189 +++++++ src/util/dataTable.js | 39 ++ src/util/filter.js | 11 +- src/util/filterInput.js | 17 +- src/util/tableColumns.js | 66 +++ src/util/tableHeaders.js | 332 ++++++++++++ src/util/tableRows.js | 108 ++++ src/util/tableSort.js | 3 +- 22 files changed, 1462 insertions(+), 558 deletions(-) create mode 100644 src/util/__tests__/tableHeaders.spec.js create mode 100644 src/util/__tests__/tableRows.spec.js create mode 100644 src/util/tableHeaders.js create mode 100644 src/util/tableRows.js diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index cd13789a94..64d3f84dfc 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -16,6 +16,10 @@ import { } from '../../actions/dataTable.js' import useDebouncedValue from '../../hooks/useDebouncedValue.js' import useKeyDown from '../../hooks/useKeyDown.js' +import { + getPanelHeights, + hasActiveDataTableFilters, +} from '../../util/dataTable.js' import { getCssVar } from '../../util/helpers.js' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' @@ -60,18 +64,21 @@ const BottomPanel = () => { const globalSearch = useDebouncedValue(searchInputValue, 200) const [headersByLayer, setHeadersByLayer] = useState(null) - const hasActiveFilters = - Object.keys(dataFilters).length > 0 || - searchInputValue.trim() !== '' || - selectionFilter?.length > 0 || - showOnlyFeaturesInView + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters, + globalSearch: searchInputValue, + selectionFilter, + showOnlyFeaturesInView, + }) - const maxHeight = - height - getCssVar('--header-height') - getCssVar('--toolbar-height') - const tableHeight = - dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const collapsedHeight = getCssVar('--data-table-controls-height') - const displayHeight = isCollapsed ? collapsedHeight : tableHeight + const { maxHeight, collapsedHeight, displayHeight } = getPanelHeights({ + windowHeight: height, + dataTableHeight, + isCollapsed, + headerHeight: getCssVar('--header-height'), + toolbarHeight: getCssVar('--toolbar-height'), + controlsHeight: getCssVar('--data-table-controls-height'), + }) const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 79ebc00b6a..29f5430638 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -29,12 +29,16 @@ import { import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, + RENDERER_COLOR, + RENDERER_ICON, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { + buildFeatureIndex, getNextSorting, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' @@ -100,9 +104,8 @@ const Table = ({ [sortField, sortDirection] ) - // Read via ref rather than a dependency, so this callback (and anything - // memoized on it, e.g. tableContext) stays stable across hovers instead - // of getting a new identity on every single mouse-enter + // Read via ref rather than a dependency, so this callback stays stable + // across hovers instead of getting a new identity on every single mouse-enter const featureRef = useRef(feature) featureRef.current = feature @@ -136,16 +139,10 @@ const Table = ({ [dispatch] ) - const featureById = useMemo(() => { - const map = new Map() - layer.data?.forEach((f) => { - const id = f.properties?.id ?? f.id - if (id != null) { - map.set(id, f) - } - }) - return map - }, [layer.data]) + const featureById = useMemo( + () => buildFeatureIndex(layer.data), + [layer.data] + ) const [tableContextMenu, setTableContextMenu] = useState(null) @@ -205,6 +202,11 @@ const Table = ({ [headers, columnConfig] ) + const rendererByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.renderer])), + [visibleHeaders] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, headers: visibleHeaders, @@ -280,10 +282,12 @@ const Table = ({ [dispatch, layer.id] ) - const hasActiveFilters = - Object.keys(layer.dataFilters ?? {}).length > 0 || - !!globalSearch?.trim() || - selectionFilter?.length > 0 + const hasActiveFilters = hasActiveDataTableFilters({ + dataFilters: layer.dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, + }) const tableContext = useMemo( () => ({ @@ -419,7 +423,7 @@ const Table = ({ {visibleHeaders.map( - ({ name, dataKey, type, optionSet }, index) => { + ({ name, dataKey, type, optionSet, renderer }, index) => { const { fixed, left, isLastPinned } = getPinnedCellProps(dataKey, index, { pinnedLeftOffsets, @@ -448,6 +452,7 @@ const Table = ({ name={name} options={columnOptions[dataKey]} optionSetId={optionSet?.id} + renderer={renderer} /> ) } @@ -580,6 +585,9 @@ const Table = ({ pinnedColumnCount, columnWidths, }) + const renderer = rendererByDataKey.get(dataKey) + const isColorCell = renderer === RENDERER_COLOR + const isIconCell = renderer === RENDERER_ICON return ( - {dataKey === 'color' && - value?.toLowerCase()} - {dataKey === 'iconUrl' && value && ( + {isColorCell && value?.toLowerCase()} + {isIconCell && value && ( )} - {dataKey !== 'color' && - dataKey !== 'iconUrl' && + {!isColorCell && + !isIconCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 5fa0633ca3..e536d6d6cb 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -9,14 +9,21 @@ import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, + RENDERER_COLOR, + RENDERER_ICON, + TYPE_NUMBER, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, + hasMatchingOptionLabel, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, } from '../../util/filterInput.js' import { getInvertibleValues, @@ -64,6 +71,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ options, resolveLabel, type, + renderer, allowCustomFilter = true, }) { const dispatch = useDispatch() @@ -104,7 +112,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ ? dispatch(setDataFilter(layerId, dataKey, text)) : dispatch(clearDataFilter(layerId, dataKey)) - const isIconColumn = dataKey === 'iconUrl' + const isIconColumn = renderer === RENDERER_ICON const renderOptionLabel = (value) => isIconColumn ? ( @@ -145,6 +153,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const font = `11px ${getComputedStyle(document.body).fontFamily}` const maxLabelWidth = measureMaxTextWidth(labels, font) return getPopoverWidth(maxLabelWidth) + // resolveLabel's identity only changes alongside type/optionSet, which don't change without realOptions changing too // eslint-disable-next-line react-hooks/exhaustive-deps }, [realOptions, hasNotSetOption]) @@ -176,9 +185,10 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ ) const hasExactMatch = useMemo( () => - filteredOptions.some( - ({ value }) => - resolveLabel(value).toLowerCase() === normalizedSearch + hasMatchingOptionLabel( + filteredOptions, + resolveLabel, + normalizedSearch ), [filteredOptions, resolveLabel, normalizedSearch] ) @@ -187,13 +197,13 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) const customFilterTag = - type === 'number' ? i18n.t('Use filter') : i18n.t('Contains') + type === TYPE_NUMBER ? i18n.t('Use filter') : i18n.t('Contains') const hasActiveFilter = selected.length > 0 || appliedString !== '' const onSearchChange = ({ value }) => { const sanitized = - type === 'number' + type === TYPE_NUMBER ? value.replace(NUMERIC_INPUT_DISALLOWED, '') : value setSearchText(sanitized) @@ -212,9 +222,10 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ } const normalized = trimmed.toLowerCase() - const exactMatch = options.some( - ({ value: optionValue }) => - resolveLabel(optionValue).toLowerCase() === normalized + const exactMatch = hasMatchingOptionLabel( + options, + resolveLabel, + normalized ) if (!exactMatch) { applyCustomFilter(trimmed) @@ -222,7 +233,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ } const scrollHighlightedIntoView = (index) => { - const optionIndex = showCustomFilterRow ? index - 1 : index + const optionIndex = toOptionIndex(index, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { listRef.current?.scrollToIndex({ index: optionIndex, @@ -242,9 +253,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ applyCustomFilter(searchText.trim()) return } - const optionIndex = showCustomFilterRow - ? highlightedIndex - 1 - : highlightedIndex + const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow) if (optionIndex >= 0 && optionIndex < filteredOptions.length) { toggleValue(filteredOptions[optionIndex].value) } @@ -255,7 +264,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ case 'ArrowDown': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount ? (i + 1) % totalCount : -1 + const next = getCyclicIndex(i, totalCount, 1) scrollHighlightedIntoView(next) return next }) @@ -263,9 +272,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ case 'ArrowUp': event.preventDefault() setHighlightedIndex((i) => { - const next = totalCount - ? (i - 1 + totalCount) % totalCount - : -1 + const next = getCyclicIndex(i, totalCount, -1) scrollHighlightedIntoView(next) return next }) @@ -297,7 +304,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ clearable dataTest={`data-table-column-filter-search-${name}`} placeholder={ - type === 'number' + type === TYPE_NUMBER ? i18n.t('Search or type > 5, < 8…') : i18n.t('Search') } @@ -316,11 +323,15 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
@@ -450,13 +461,14 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ className={cx( styles.denseCheckbox, (dataKey === 'id' || - dataKey === 'color') && + renderer === + RENDERER_COLOR) && styles.monoOption, highlightedIndex === - (showCustomFilterRow - ? index + 1 - : index) && - styles.highlighted + toHighlightedIndex( + index, + showCustomFilterRow + ) && styles.highlighted )} /> )} @@ -483,6 +495,7 @@ SearchableFilterPopover.propTypes = { PropTypes.arrayOf(PropTypes.string), ]), layerId: PropTypes.string, + renderer: PropTypes.string, } const PlainSearchableFilter = (props) => { @@ -496,7 +509,7 @@ const PlainSearchableFilter = (props) => { if (value === SENTINEL_NO_VALUE) { return i18n.t('No value') } - return type === 'number' + return type === TYPE_NUMBER ? formatWithSeparator( Number(value), keyAnalysisDigitGroupSeparator @@ -546,6 +559,7 @@ const FilterInput = React.memo(function FilterInput({ name, options, optionSetId, + renderer, }) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -571,6 +585,7 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} optionSetId={optionSetId} type={type} + renderer={renderer} /> ) : ( ) }) @@ -590,6 +606,7 @@ FilterInput.propTypes = { type: PropTypes.string.isRequired, optionSetId: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), + renderer: PropTypes.string, } export default FilterInput diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index c7414136db..928d2e6da3 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -205,6 +205,7 @@ describe('FilterInput multi-select path (no optionSetId)', () => { renderFilterInput({ dataKey: 'iconUrl', name: 'Icon', + renderer: 'rendericon', options: [{ value: 'https://server/api/icons/mapMarker024.png' }], }) openPopover('Icon') diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx index 11f555940e..fe7ca0a70f 100644 --- a/src/components/datatable/controls/ColumnPickerControl.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -16,7 +16,6 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' -import { arrayMoveImmutable } from 'array-move' import cx from 'classnames' import PropTypes from 'prop-types' import React, { @@ -30,10 +29,12 @@ import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' import { setDataTableColumnConfig } from '../../../actions/dataTable.js' import { + filterHeadersByName, getDefaultVisibleKeys, getOrderedHeaders, getPinnedCount, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -134,9 +135,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ const filteredHeaders = useMemo( () => isOpen - ? orderedHeaders.filter((h) => - h.name.toLowerCase().includes(search.trim().toLowerCase()) - ) + ? filterHeadersByName(orderedHeaders, search) : EMPTY_HEADERS, [isOpen, orderedHeaders, search] ) @@ -157,19 +156,12 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({ setActiveId(null) if (over && active.id !== over.id) { - const oldIndex = orderedHeaders.findIndex( - (h) => h.dataKey === active.id + const nextOrder = reorderHeaderKeys( + orderedHeaders, + active.id, + over.id ) - const newIndex = orderedHeaders.findIndex( - (h) => h.dataKey === over.id - ) - - if (oldIndex !== -1 && newIndex !== -1) { - const nextOrder = arrayMoveImmutable( - orderedHeaders, - oldIndex, - newIndex - ).map((h) => h.dataKey) + if (nextOrder) { updateConfig({ orderedKeys: nextOrder }) } } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 8afb7e574c..f91988f8bd 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -2,18 +2,13 @@ import i18n from '@dhis2/d2-i18n' import { useDeferredValue, useMemo, useRef } from 'react' import { useSelector } from 'react-redux' import { - SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, SORT_ASCENDING, } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, - ORG_UNIT_LAYER, EARTH_ENGINE_LAYER, - FACILITY_LAYER, - GEOJSON_URL_LAYER, - TRACKED_ENTITY_LAYER, RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, } from '../../constants/layers.js' @@ -21,41 +16,24 @@ import { SELECTION_FILTER_SELECTED, SELECTION_FILTER_NOT_SELECTED, } from '../../constants/selection.js' -import { numberValueTypes } from '../../constants/valueTypes.js' -import { hasClasses } from '../../util/earthEngine.js' import { filterByGlobalSearch, filterData } from '../../util/filter.js' -import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' import { - formatRangeWithSeparator, - getRoundToPrecisionFn, - getPrecision, -} from '../../util/numbers.js' + buildRowCells, + getColumnDistinctValues, +} from '../../util/tableColumns.js' +import { + TYPE_STRING, + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, +} from '../../util/tableHeaders.js' +import { + ERROR_SERVER_CLUSTER, + ERROR_NO_VALID_DATA, + buildTableData, +} from '../../util/tableRows.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' -import { isValidUid } from '../../util/uid.js' - -const TYPE_NUMBER = 'number' -const TYPE_STRING = 'string' -const TYPE_DATE = 'date' -const NAME = 'name' -const ID = 'id' -const VALUE = 'rawValue' -const LEGEND = 'legend' -const RANGE = 'range' -const LEVEL = 'level' -const PARENT_NAME = 'parentName' -const TYPE = 'type' -const COLOR = 'color' -const GROUP = 'group' -const ICON = 'iconUrl' -const OUNAME = 'ouname' -const OUBOUNDARY = 'ouBoundary' -const EVENTDATE = 'eventdate' - -const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' -const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' const ERROR_NO_HEADERS = 'NO_HEADERS' -const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' const getErrorCodeText = (code) => { switch (code) { @@ -78,240 +56,6 @@ const getErrorCodeText = (code) => { } } -const defaultFieldsMap = () => ({ - [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, - [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, - [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, - [PARENT_NAME]: { - name: i18n.t('Parent'), - dataKey: PARENT_NAME, - type: TYPE_STRING, - }, - [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, - [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, - [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, - [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, - [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, - [OUBOUNDARY]: { - name: i18n.t('Org unit boundary'), - dataKey: OUBOUNDARY, - type: TYPE_STRING, - }, - [EVENTDATE]: { - name: i18n.t('Event time'), - dataKey: EVENTDATE, - type: TYPE_DATE, - renderer: 'formatTime...', - }, - [COLOR]: { - name: i18n.t('Color'), - dataKey: COLOR, - type: TYPE_STRING, - renderer: 'rendercolor', - }, - [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, - [ICON]: { - name: i18n.t('Icon'), - dataKey: ICON, - type: TYPE_STRING, - renderer: 'rendericon', - }, -}) - -const getStyleHeaders = ({ - hasLegend, - hasRange, - hasGroup, - hasColor, - hasIcon, -}) => { - const headers = [] - if (hasLegend) { - headers.push(defaultFieldsMap()[LEGEND]) - } - if (hasRange) { - headers.push(defaultFieldsMap()[RANGE]) - } - if (hasGroup) { - headers.push(defaultFieldsMap()[GROUP]) - } - if (hasColor) { - headers.push(defaultFieldsMap()[COLOR]) - } - if (hasIcon) { - headers.push(defaultFieldsMap()[ICON]) - } - return headers -} - -const getThematicHeaders = () => - [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat( - getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) - ) - -const getMultiPeriodThematicHeaders = ({ - isTimelineThematic, - externalPeriod, - periods, -}) => { - const headers = isTimelineThematic - ? getThematicHeaders().map((header) => - [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) - ? { - ...header, - name: `${header.name} (${ - externalPeriod?.name ?? i18n.t('Current period') - })`, - } - : header - ) - : getOrgUnitHeaders() - - const otherPeriods = isTimelineThematic - ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) - : periods ?? [] - - otherPeriods.forEach((period) => { - headers.push({ - name: i18n.t('Value ({{period}})', { period: period.name }), - dataKey: `period_${period.id}_rawValue`, - type: TYPE_NUMBER, - defaultHidden: true, - }) - }) - - return headers -} - -const getEventHeaders = ({ - layerHeaders = [], - styleDataItem, - countEventsOutsideOrgUnits, -}) => { - const fields = [OUNAME, ID, EVENTDATE].map( - (field) => defaultFieldsMap()[field] - ) - - if (countEventsOutsideOrgUnits) { - fields.push(defaultFieldsMap()[OUBOUNDARY]) - } - - const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ - name, - dataKey, - type: - !optionSet && numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - optionSet: optionSet || null, - })) - - customFields.push( - defaultFieldsMap()[TYPE], - ...getStyleHeaders({ - hasLegend: !!styleDataItem, - hasRange: !!styleDataItem, - hasColor: !!styleDataItem, - }) - ) - - return fields.concat(customFields) -} - -const getOrgUnitStyleHeaders = (data) => { - let hasGroup = false - let hasColor = false - let hasIcon = false - - for (const d of data ?? []) { - hasGroup ||= d.group != null - hasColor ||= d.color != null - hasIcon ||= d.iconUrl != null - - if (hasGroup && hasColor && hasIcon) { - break - } - } - - return getStyleHeaders({ hasGroup, hasColor, hasIcon }) -} - -const getOrgUnitHeaders = (data) => - [NAME, ID, LEVEL, PARENT_NAME, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(getOrgUnitStyleHeaders(data)) - -const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { - const fields = [ID].map((field) => defaultFieldsMap()[field]) - - const customFields = layerHeaders - .filter(({ dataKey }) => isValidUid(dataKey)) - .map(({ name, dataKey, valueType }) => ({ - name, - dataKey, - type: numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - })) - - customFields.push(...getStyleHeaders({ hasColor: true })) - - return fields.concat(customFields) -} - -const getFacilityHeaders = (data) => - [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(getOrgUnitStyleHeaders(data)) - -const toTitleCase = (str) => - str.replace( - /\w\S*/g, - (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - ) - -const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { - const { title, items } = legend - - let customFields = [] - - if (hasClasses(aggregationType) && items) { - customFields = items.map(({ value, name }) => ({ - name, - dataKey: String(value), - roundFn: getRoundToPrecisionFn(2), - type: TYPE_NUMBER, - })) - } else if (Array.isArray(aggregationType) && aggregationType.length) { - customFields = aggregationType.map((type) => { - let roundFn = null - if (data?.length) { - const precision = getPrecision(data.map((d) => d[type])) - roundFn = getRoundToPrecisionFn(precision) - } - return { - name: toTitleCase(`${type} ${title}`), - dataKey: type, - roundFn, - type: TYPE_NUMBER, - } - }) - } - - return [NAME, ID, TYPE] - .map((field) => defaultFieldsMap()[field]) - .concat(customFields) -} - -const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem).map((header) => - header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header - ) - const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} const EMPTY_COLUMN_OPTIONS = {} @@ -375,92 +119,29 @@ export const useTableData = ({ const externalPeriodDependency = isTimelineThematic ? externalPeriod : null const dataWithAggregations = useMemo(() => { - errorCode.current = null - if (serverCluster) { - errorCode.current = ERROR_SERVER_CLUSTER - return null - } - - const allData = dataWithoutCoords?.length - ? [...(data || []), ...dataWithoutCoords] - : data - - if (!allData?.length) { - errorCode.current = ERROR_NO_VALID_DATA - return null - } - - const inViewData = showOnlyFeaturesInView - ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) - : allData - - if (layerType === GEOJSON_URL_LAYER) { - return inViewData.map((d) => ({ - ...d.properties, - })) - } - - return inViewData - .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => { - const properties = d.properties || d - - if (isStyledEvent) { - const legendItem = legend?.items?.[properties.colorGroup] - return { - ...properties, - legend: legendItem?.name, - range: - legendItem && 'startValue' in legendItem - ? formatRangeWithSeparator( - legendItem, - keyAnalysisDigitGroupSeparator, - { precision: legendDecimalPlaces } - ) - : undefined, - ...aggregations[d.id], - index, - } - } - - if (!isMultiPeriodThematic) { - return { - ...properties, - ...aggregations[d.id], - // Row-order tie-breaker for compareRows when no sortField is set - index, - } - } - - const orgUnitId = properties.id - const currentPeriodItem = isTimelineThematic - ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] - : null - const otherPeriodValues = {} - ;(periods ?? []).forEach((period) => { - if ( - isTimelineThematic && - period.id === externalPeriod?.id - ) { - return - } - otherPeriodValues[`period_${period.id}_rawValue`] = - valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null - }) + const { data: rows, errorCode: rowsErrorCode } = buildTableData( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } + ) - return { - ...properties, - ...(currentPeriodItem && { - rawValue: currentPeriodItem.value, - color: currentPeriodItem.color, - legend: currentPeriodItem.legend, - range: currentPeriodItem.range, - }), - ...otherPeriodValues, - ...aggregations[d.id], - index, - } - }) + errorCode.current = rowsErrorCode ?? null + return rowsErrorCode ? null : rows // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -487,56 +168,26 @@ export const useTableData = ({ return null } - let headers = null - switch (layerType) { - case THEMATIC_LAYER: - headers = isMultiPeriodThematic - ? getMultiPeriodThematicHeaders({ - isTimelineThematic, - externalPeriod, - periods, - }) - : getThematicHeaders() - break - case EVENT_LAYER: - headers = getEventHeaders({ - layerHeaders, - styleDataItem, - countEventsOutsideOrgUnits, - }) - break - case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders(dataWithAggregations) - break - case TRACKED_ENTITY_LAYER: - headers = getTrackedEntityHeaders({ layerHeaders }) - break - case EARTH_ENGINE_LAYER: - headers = getEarthEngineHeaders({ - aggregationType, - legend, - data: dataWithAggregations, - }) - break - case FACILITY_LAYER: - headers = getFacilityHeaders(dataWithAggregations) - break - case GEOJSON_URL_LAYER: { - if ( - data.some( - (feature) => - feature.geometry.type !== data[0].geometry.type - ) - ) { - errorCode.current = ERROR_NON_HOMOGENOUS_FEATURES - return null - } - - headers = getGeoJsonUrlHeaders(data[0]) - break + const { headers, errorCode: headersErrorCode } = getHeadersForLayer( + layerType, + { + isMultiPeriodThematic, + isTimelineThematic, + externalPeriod, + periods, + layerHeaders, + styleDataItem, + countEventsOutsideOrgUnits, + aggregationType, + legend, + data: dataWithAggregations, + rawData: data, } - default: - break + ) + + if (headersErrorCode) { + errorCode.current = headersErrorCode + return null } if (!headers?.length) { @@ -563,30 +214,10 @@ export const useTableData = ({ // Expensive: scans every row once per column const deferredDataForOptions = useDeferredValue(dataWithAggregations) - const columnDistinctValues = useMemo(() => { - if (!headers?.length || !deferredDataForOptions?.length) { - return null - } - - const result = {} - headers.forEach(({ dataKey, type }) => { - const seen = new Set() - for (const item of deferredDataForOptions) { - const val = item[dataKey] - seen.add( - val === undefined || val === null || val === '' - ? SENTINEL_NO_VALUE - : String(val) - ) - } - - if (seen.size > 0) { - result[dataKey] = { values: Array.from(seen), type } - } - }) - - return result - }, [headers, deferredDataForOptions]) + const columnDistinctValues = useMemo( + () => getColumnDistinctValues(headers, deferredDataForOptions), + [headers, deferredDataForOptions] + ) // Cheap: just re-orders each column's already-known distinct-value list const columnOptions = useMemo(() => { @@ -657,18 +288,7 @@ export const useTableData = ({ compareRows(a, b, { sortField, sortDirection, selectedIdSet }) ) - return filteredData.map((item) => - headers.map(({ dataKey, roundFn, type }) => { - const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] - - return { - dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, - align: type === TYPE_NUMBER ? 'right' : 'left', - itemId: item.id, - } - }) - ) + return filteredData.map((item) => buildRowCells(item, headers)) // *Dependency vars proxy their raw counterparts (see above) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 10d2e59675..94d4a9cb05 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -4,3 +4,10 @@ export const SENTINEL_SELECTED_ROW = '__selected__' export const SORT_ASCENDING = 'asc' export const SORT_DESCENDING = 'desc' + +export const RENDERER_COLOR = 'rendercolor' +export const RENDERER_ICON = 'rendericon' + +export const TYPE_NUMBER = 'number' +export const TYPE_STRING = 'string' +export const TYPE_DATE = 'date' diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index e25cda3c91..4f322ca4e4 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -1,7 +1,7 @@ import { getAttributeHeaders, getAttributeProperties, - parseJsonConfig, + applyParsedConfig, toGeoJson, } from '../trackedEntityLoader.js' @@ -65,7 +65,7 @@ describe('getAttributeHeaders', () => { }) }) -describe('parseJsonConfig', () => { +describe('applyParsedConfig', () => { it('extracts periodType when relationships is null', () => { const config = { config: JSON.stringify({ @@ -73,7 +73,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBeUndefined() expect(config.config).toBeUndefined() @@ -92,7 +92,7 @@ describe('parseJsonConfig', () => { periodType: 'program', }), } - parseJsonConfig(config) + applyParsedConfig(config) expect(config.periodType).toBe('program') expect(config.relationshipType).toBe('rel-type-id') expect(config.relatedPointColor).toBe('#ff0000') @@ -104,13 +104,13 @@ describe('parseJsonConfig', () => { it('does nothing when config.config is absent', () => { const config = { layer: 'trackedEntity' } - parseJsonConfig(config) + applyParsedConfig(config) expect(config).toEqual({ layer: 'trackedEntity' }) }) it('does not throw and leaves config intact on malformed JSON', () => { const config = { config: 'not-valid-json' } - expect(() => parseJsonConfig(config)).not.toThrow() + expect(() => applyParsedConfig(config)).not.toThrow() expect(config.periodType).toBeUndefined() expect(config.config).toBeUndefined() }) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 5d01d4fdbd..fbe8feac80 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -179,6 +179,7 @@ const thematicLoader = async ({ legend: null, isLoaded: true, isLoading: false, + isExpanded: true, loadError, } } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 4431dfeec8..7373e0f3c8 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -9,6 +9,7 @@ import { } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { GEO_TYPE_POINT, GEO_TYPE_POLYGON, @@ -138,32 +139,23 @@ export const toGeoJson = (instances, color) => }, })) -export const parseJsonConfig = (config) => { - if (!config.config || typeof config.config !== 'string') { - return +export const applyParsedConfig = (config) => { + const { relationships, periodType, dataTableColumnConfig } = + parseJsonConfig(config.config) + + if (relationships) { + config.relationshipType = relationships.type + config.relatedPointColor = relationships.pointColor + config.relatedPointRadius = relationships.pointRadius + config.relationshipLineColor = relationships.lineColor + config.relationshipOutsideProgram = + relationships.relationshipOutsideProgram } - try { - const { relationships, periodType, dataTableColumnConfig } = JSON.parse( - config.config - ) - - if (relationships) { - config.relationshipType = relationships.type - config.relatedPointColor = relationships.pointColor - config.relatedPointRadius = relationships.pointRadius - config.relationshipLineColor = relationships.lineColor - config.relationshipOutsideProgram = - relationships.relationshipOutsideProgram - } - - config.periodType = periodType + config.periodType = periodType - if (dataTableColumnConfig) { - config.dataTableColumnConfig = dataTableColumnConfig - } - } catch (e) { - // Malformed config JSON + if (dataTableColumnConfig) { + config.dataTableColumnConfig = dataTableColumnConfig } delete config.config @@ -274,7 +266,7 @@ const trackedEntityLoader = async ({ keyAnalysisDigitGroupSeparator, serverVersion, }) => { - parseJsonConfig(config) + applyParsedConfig(config) const { trackedEntityType, diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js index 94d7c6026d..27484a9981 100644 --- a/src/util/__tests__/dataTable.spec.js +++ b/src/util/__tests__/dataTable.spec.js @@ -1,7 +1,10 @@ import { + buildFeatureIndex, getNextSorting, + getPanelHeights, getRowClickAction, getRowId, + hasActiveDataTableFilters, isFilterable, shouldClearFeatureHighlight, } from '../dataTable.js' @@ -129,3 +132,118 @@ describe('isFilterable', () => { expect(isFilterable('someKey', undefined)).toBe(false) }) }) + +describe('hasActiveDataTableFilters', () => { + const empty = { + dataFilters: {}, + globalSearch: '', + selectionFilter: [], + showOnlyFeaturesInView: false, + } + + test('is false when nothing is filtered', () => { + expect(hasActiveDataTableFilters(empty)).toBe(false) + }) + + test('is true when a column filter is set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + dataFilters: { name: 'foo' }, + }) + ).toBe(true) + }) + + test('is true for a non-blank global search, trimmed', () => { + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' ' }) + ).toBe(false) + expect( + hasActiveDataTableFilters({ ...empty, globalSearch: ' foo ' }) + ).toBe(true) + }) + + test('is true when a selection filter is applied', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + selectionFilter: ['selected'], + }) + ).toBe(true) + }) + + test('is true when showOnlyFeaturesInView is on, even with nothing else set', () => { + expect( + hasActiveDataTableFilters({ + ...empty, + showOnlyFeaturesInView: true, + }) + ).toBe(true) + }) +}) + +describe('buildFeatureIndex', () => { + test('indexes features by properties.id when present', () => { + const data = [{ properties: { id: 'a' } }, { properties: { id: 'b' } }] + const index = buildFeatureIndex(data) + expect(index.get('a')).toBe(data[0]) + expect(index.get('b')).toBe(data[1]) + }) + + test('falls back to the feature’s own top-level id', () => { + const feature = { id: 'a', properties: {} } + expect(buildFeatureIndex([feature]).get('a')).toBe(feature) + }) + + test('skips features with no id anywhere', () => { + const index = buildFeatureIndex([{ properties: {} }]) + expect(index.size).toBe(0) + }) + + test('returns an empty index for missing/empty data', () => { + expect(buildFeatureIndex(undefined).size).toBe(0) + expect(buildFeatureIndex([]).size).toBe(0) + }) +}) + +describe('getPanelHeights', () => { + test('clamps the table height to the window, minus header/toolbar', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 1000, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result).toEqual({ + maxHeight: 700, + collapsedHeight: 32, + displayHeight: 700, + }) + }) + + test('uses the saved height as-is when it already fits', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: false, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(300) + }) + + test('collapses to just the controls height, regardless of the saved height', () => { + const result = getPanelHeights({ + windowHeight: 800, + dataTableHeight: 300, + isCollapsed: true, + headerHeight: 50, + toolbarHeight: 50, + controlsHeight: 32, + }) + expect(result.displayHeight).toBe(32) + }) +}) diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js index 48c894fa6c..747d6063fe 100644 --- a/src/util/__tests__/filterInput.spec.js +++ b/src/util/__tests__/filterInput.spec.js @@ -1,9 +1,13 @@ import { + getCyclicIndex, getDisplayValue, getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, + hasMatchingOptionLabel, measureMaxTextWidth, + toHighlightedIndex, + toOptionIndex, } from '../filterInput.js' describe('getSelectedAndAppliedString', () => { @@ -136,3 +140,58 @@ describe('getPopoverWidth', () => { expect(getPopoverWidth(100)).toBe(156) }) }) + +describe('hasMatchingOptionLabel', () => { + const options = [{ value: 'a' }, { value: 'b' }] + const resolveLabel = (v) => ({ a: 'Apple', b: 'Banana' }[v]) + + it('is true when some option resolves to exactly the given text', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'apple')).toBe( + true + ) + }) + + it('is false for a partial match', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'app')).toBe(false) + }) + + it('is false when no option matches', () => { + expect(hasMatchingOptionLabel(options, resolveLabel, 'cherry')).toBe( + false + ) + }) +}) + +describe('getCyclicIndex', () => { + it('moves forward within range', () => { + expect(getCyclicIndex(0, 3, 1)).toBe(1) + }) + + it('wraps from the last index back to the first when moving forward', () => { + expect(getCyclicIndex(2, 3, 1)).toBe(0) + }) + + it('moving backward from -1 (nothing highlighted) lands on index 1, matching the pre-existing arithmetic', () => { + expect(getCyclicIndex(-1, 3, -1)).toBe(1) + }) + + it('moves backward within range', () => { + expect(getCyclicIndex(2, 3, -1)).toBe(1) + }) + + it('returns -1 when there is nothing to highlight', () => { + expect(getCyclicIndex(0, 0, 1)).toBe(-1) + }) +}) + +describe('toOptionIndex / toHighlightedIndex', () => { + it('are unchanged when the custom-filter row is not shown', () => { + expect(toOptionIndex(2, false)).toBe(2) + expect(toHighlightedIndex(2, false)).toBe(2) + }) + + it('are offset by one, and invert each other, when the custom-filter row is shown', () => { + expect(toOptionIndex(1, true)).toBe(0) + expect(toHighlightedIndex(0, true)).toBe(1) + }) +}) diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js index c061aeeadc..39efc86bd9 100644 --- a/src/util/__tests__/tableColumns.spec.js +++ b/src/util/__tests__/tableColumns.spec.js @@ -1,4 +1,8 @@ +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../../constants/dataTable.js' import { + buildRowCells, + filterHeadersByName, + getColumnDistinctValues, getDefaultVisibleKeys, getOrderedHeaders, getPinnedCellProps, @@ -6,6 +10,7 @@ import { getPinnedLeftOffsets, getVisibleHeaders, isPinnedGroupEnd, + reorderHeaderKeys, reverseVisibleKeys, togglePinnedKey, toggleVisibleKey, @@ -399,3 +404,95 @@ describe('getDefaultVisibleKeys', () => { ]) }) }) + +describe('getColumnDistinctValues', () => { + const typedHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('returns null when there are no headers or no data yet', () => { + expect(getColumnDistinctValues([], [{ name: 'A' }])).toBe(null) + expect(getColumnDistinctValues(typedHeaders, [])).toBe(null) + }) + + it('collects the distinct string value of each column across all rows', () => { + const data = [ + { name: 'A', rawValue: 1 }, + { name: 'B', rawValue: 2 }, + { name: 'A', rawValue: 1 }, + ] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name).toEqual({ values: ['A', 'B'], type: 'string' }) + expect(result.rawValue).toEqual({ + values: ['1', '2'], + type: TYPE_NUMBER, + }) + }) + + it('coalesces undefined/null/empty-string values to the sentinel and omits a column with none at all', () => { + const data = [{ name: '' }, { name: null }, { rawValue: 5 }] + const result = getColumnDistinctValues(typedHeaders, data) + expect(result.name.values).toEqual([SENTINEL_NO_VALUE]) + expect(result.rawValue.values).toEqual([SENTINEL_NO_VALUE, '5']) + }) +}) + +describe('buildRowCells', () => { + const rowHeaders = [ + { dataKey: 'name', type: 'string' }, + { dataKey: 'rawValue', type: TYPE_NUMBER }, + ] + + it('builds one cell per header, aligning numbers right and everything else left', () => { + const item = { id: 'a', name: 'Alpha', rawValue: 5 } + expect(buildRowCells(item, rowHeaders)).toEqual([ + { dataKey: 'name', value: 'Alpha', align: 'left', itemId: 'a' }, + { dataKey: 'rawValue', value: 5, align: 'right', itemId: 'a' }, + ]) + }) + + it('applies a column roundFn before returning the value', () => { + const item = { id: 'a', rawValue: 1.23456 } + const withRoundFn = [ + { dataKey: 'rawValue', type: TYPE_NUMBER, roundFn: Math.round }, + ] + expect(buildRowCells(item, withRoundFn)[0].value).toBe(1) + }) + + it('nulls out a non-numeric value in a number column instead of returning NaN', () => { + const item = { id: 'a', rawValue: 'not-a-number' } + expect(buildRowCells(item, rowHeaders)[1].value).toBe(null) + }) +}) + +describe('filterHeadersByName', () => { + it('keeps headers whose name contains the search text, case-insensitively', () => { + const result = filterHeadersByName(headers, 'AME') + expect(result.map((h) => h.dataKey)).toEqual(['name']) + }) + + it('trims the search text before matching', () => { + const result = filterHeadersByName(headers, ' id ') + expect(result.map((h) => h.dataKey)).toEqual(['id']) + }) + + it('returns every header when the search text is empty', () => { + expect(filterHeadersByName(headers, '')).toEqual(headers) + }) +}) + +describe('reorderHeaderKeys', () => { + it('moves the active header to the dropped-on header’s position', () => { + const result = reorderHeaderKeys(headers, 'name', 'legend') + expect(result).toEqual(['id', 'rawValue', 'legend', 'name']) + }) + + it('returns null when the active header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'deletedColumn', 'legend')).toBe(null) + }) + + it('returns null when the drop-target header can no longer be found', () => { + expect(reorderHeaderKeys(headers, 'name', 'deletedColumn')).toBe(null) + }) +}) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js new file mode 100644 index 0000000000..c3e0f0d798 --- /dev/null +++ b/src/util/__tests__/tableHeaders.spec.js @@ -0,0 +1,234 @@ +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' +import { + ERROR_NON_HOMOGENOUS_FEATURES, + getHeadersForLayer, + TYPE_NUMBER, + TYPE_STRING, +} from '../tableHeaders.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +const dataKeys = (result) => result.headers.map((h) => h.dataKey) + +describe('getHeadersForLayer - thematic', () => { + test('single-period: fixed fields plus legend/range/color', () => { + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: false, + }) + expect(dataKeys(result)).toEqual([ + 'name', + 'id', + 'rawValue', + 'level', + 'parentName', + 'type', + 'legend', + 'range', + 'color', + ]) + }) + + test('multi-period, non-timeline: org unit headers plus one column per other period', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: false, + periods, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'period_p1_rawValue', + 'period_p2_rawValue', + ]) + ) + }) + + test('multi-period timeline: excludes the external period from the extra columns and labels value/legend/range/color with it', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const externalPeriod = { id: 'p1', name: 'Jan' } + const result = getHeadersForLayer(THEMATIC_LAYER, { + isMultiPeriodThematic: true, + isTimelineThematic: true, + periods, + externalPeriod, + }) + expect(dataKeys(result)).not.toContain('period_p1_rawValue') + expect(dataKeys(result)).toContain('period_p2_rawValue') + const valueHeader = result.headers.find((h) => h.dataKey === 'rawValue') + expect(valueHeader.name).toContain('Jan') + }) +}) + +describe('getHeadersForLayer - event', () => { + test('fixed org unit/id/eventdate fields plus valid-uid custom fields from layerHeaders', () => { + const layerHeaders = [ + { + name: 'w75KJ2mc4zz', + column: 'Age', + valueType: 'INTEGER', + }, + { name: 'not-a-uid', column: 'Ignored', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['ouname', 'id', 'eventdate', 'w75KJ2mc4zz']) + ) + expect(dataKeys(result)).not.toContain('not-a-uid') + const ageHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(ageHeader.type).toBe(TYPE_NUMBER) + }) + + test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { + const without = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const withBoundary = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + countEventsOutsideOrgUnits: true, + }) + expect(dataKeys(without)).not.toContain('ouBoundary') + expect(dataKeys(withBoundary)).toContain('ouBoundary') + }) + + test('adds legend/range/color only when styled by a data item', () => { + const unstyled = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] }) + const styled = getHeadersForLayer(EVENT_LAYER, { + layerHeaders: [], + styleDataItem: { id: 'abc' }, + }) + expect(dataKeys(unstyled)).not.toContain('color') + expect(dataKeys(styled)).toEqual( + expect.arrayContaining(['legend', 'range', 'color']) + ) + }) +}) + +describe('getHeadersForLayer - org unit / facility', () => { + test('org unit: fixed fields plus whichever style columns the data actually has', () => { + const result = getHeadersForLayer(ORG_UNIT_LAYER, { + data: [{ color: '#fff' }, { iconUrl: 'x.png' }], + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining([ + 'name', + 'id', + 'level', + 'parentName', + 'type', + 'color', + 'iconUrl', + ]) + ) + expect(dataKeys(result)).not.toContain('group') + }) + + test('facility: same style-detection behavior as org unit, with a smaller fixed field set', () => { + const result = getHeadersForLayer(FACILITY_LAYER, { + data: [{ group: 'g1' }], + }) + expect(dataKeys(result)).toEqual(['name', 'id', 'type', 'group']) + }) +}) + +describe('getHeadersForLayer - tracked entity', () => { + test('id field plus valid-uid custom fields from layerHeaders, always with a color column', () => { + const layerHeaders = [ + { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' }, + { name: 'Bad', dataKey: 'not-a-uid', valueType: 'TEXT' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + expect(dataKeys(result)).toEqual(['id', 'w75KJ2mc4zz', 'color']) + const nameHeader = result.headers.find( + (h) => h.dataKey === 'w75KJ2mc4zz' + ) + expect(nameHeader.type).toBe(TYPE_STRING) + }) +}) + +describe('getHeadersForLayer - earth engine', () => { + test('class-based aggregation: one column per legend item, rounded to 2 decimal places', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: 'percentage', + legend: { + title: 'Land cover', + items: [{ value: 1, name: 'Forest' }], + }, + }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'id', 'type', '1']) + ) + const classHeader = result.headers.find((h) => h.dataKey === '1') + expect(classHeader.name).toBe('Forest') + expect(classHeader.roundFn(1.23456)).toBe(1.23) + }) + + test('non-class aggregation array: one title-cased column per aggregation type', () => { + const result = getHeadersForLayer(EARTH_ENGINE_LAYER, { + aggregationType: ['mean'], + legend: { title: 'Rainfall', items: [] }, + data: [{ mean: 12.3456 }], + }) + const meanHeader = result.headers.find((h) => h.dataKey === 'mean') + expect(meanHeader.name).toBe('Mean Rainfall') + expect(meanHeader.type).toBe(TYPE_NUMBER) + }) +}) + +describe('getHeadersForLayer - geoJsonUrl', () => { + test('homogenous features: derives headers from the first feature', () => { + const rawData = [ + { + geometry: { type: 'Point' }, + properties: { name: 'A', color: '#f00' }, + }, + { + geometry: { type: 'Point' }, + properties: { name: 'B', color: '#0f0' }, + }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(dataKeys(result)).toEqual( + expect.arrayContaining(['name', 'color']) + ) + }) + + test('non-homogenous geometry types: returns an error code instead of headers', () => { + const rawData = [ + { geometry: { type: 'Point' }, properties: {} }, + { geometry: { type: 'LineString' }, properties: {} }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result).toEqual({ errorCode: ERROR_NON_HOMOGENOUS_FEATURES }) + }) +}) + +describe('getHeadersForLayer - unknown layer type', () => { + test('returns null headers rather than throwing', () => { + expect(getHeadersForLayer('somethingElse', {})).toEqual({ + headers: null, + }) + }) +}) diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js new file mode 100644 index 0000000000..fffcf07bf9 --- /dev/null +++ b/src/util/__tests__/tableRows.spec.js @@ -0,0 +1,189 @@ +import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + buildTableData, + ERROR_NO_VALID_DATA, + ERROR_SERVER_CLUSTER, +} from '../tableRows.js' + +// Thematic-layer-shaped feature: id is stamped on both the top level (which +// is what aggregations are keyed by) and properties (see the deferred +// id-placement inconsistency called out for this codebase's loaders). +const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ + id, + geometry: { type: 'Point', coordinates }, + properties: { id, ...extraProperties }, +}) + +describe('buildTableData - error paths', () => { + test('server-clustered layers return an error code instead of data', () => { + expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( + { errorCode: ERROR_SERVER_CLUSTER } + ) + }) + + test('no data and no dataWithoutCoords returns a no-valid-data error', () => { + expect( + buildTableData(THEMATIC_LAYER, { data: [], dataWithoutCoords: [] }) + ).toEqual({ errorCode: ERROR_NO_VALID_DATA }) + expect(buildTableData(THEMATIC_LAYER, {})).toEqual({ + errorCode: ERROR_NO_VALID_DATA, + }) + }) +}) + +describe('buildTableData - geoJsonUrl layer', () => { + test('returns each feature’s properties as a row, bypassing the hasAdditionalGeometry filter', () => { + const data = [ + feature('a', { name: 'A', hasAdditionalGeometry: true }), + feature('b', { name: 'B' }), + ] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', hasAdditionalGeometry: true }, + { id: 'b', name: 'B' }, + ]) + }) +}) + +describe('buildTableData - showOnlyFeaturesInView', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const bounds = [0, 0, 20, 20] + + test('keeps all features when showOnlyFeaturesInView is off', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: false, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in', 'out']) + }) + + test('filters out features outside the given bounds when showOnlyFeaturesInView is on', () => { + const result = buildTableData(THEMATIC_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: bounds, + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + +describe('buildTableData - generic layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { name: 'A' })] + const dataWithoutCoords = [ + feature('b', { name: 'B', hasAdditionalGeometry: true }), + feature('c', { name: 'C' }), + ] + const result = buildTableData(THEMATIC_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', name: 'A', count: 5, index: 0 }, + { id: 'c', name: 'C', index: 1 }, + ]) + }) +}) + +describe('buildTableData - styled event layer', () => { + test('derives legend name and a formatted range from the matching legend item', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { + items: { + 0: { name: 'Low', startValue: 0, endValue: 10 }, + }, + } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + keyAnalysisDigitGroupSeparator: 'SPACE', + }) + expect(result.data[0].legend).toBe('Low') + expect(result.data[0].range).toBe('0 – 10') + }) + + test('leaves range undefined when the matched legend item has no start/end value', () => { + const data = [feature('a', { colorGroup: 0 })] + const legend = { items: { 0: { name: 'Uncategorized' } } } + const result = buildTableData('event', { + data, + aggregations: {}, + isStyledEvent: true, + legend, + }) + expect(result.data[0].legend).toBe('Uncategorized') + expect(result.data[0].range).toBeUndefined() + }) +}) + +describe('buildTableData - multi-period thematic layer', () => { + const periods = [ + { id: 'p1', name: 'Jan' }, + { id: 'p2', name: 'Feb' }, + ] + const valuesByPeriod = { + p1: { a: { value: 1, color: '#f00', legend: 'Low', range: '0-1' } }, + p2: { a: { value: 2 } }, + } + + test('timeline: overlays the external period’s value/color/legend/range and adds one column per other period', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: true, + valuesByPeriod, + externalPeriod: periods[0], + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + rawValue: 1, + color: '#f00', + legend: 'Low', + range: '0-1', + period_p2_rawValue: 2, + }) + expect(result.data[0].period_p1_rawValue).toBeUndefined() + }) + + test('split (non-timeline): adds one column per period, with no current-period overlay', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod, + periods, + }) + expect(result.data[0]).toMatchObject({ + id: 'a', + period_p1_rawValue: 1, + period_p2_rawValue: 2, + }) + expect(result.data[0].rawValue).toBeUndefined() + }) + + test('falls back to null for a period with no recorded value for that org unit', () => { + const data = [feature('a')] + const result = buildTableData(THEMATIC_LAYER, { + data, + aggregations: {}, + isMultiPeriodThematic: true, + isTimelineThematic: false, + valuesByPeriod: { p1: {} }, + periods, + }) + expect(result.data[0].period_p1_rawValue).toBeNull() + expect(result.data[0].period_p2_rawValue).toBeNull() + }) +}) diff --git a/src/util/dataTable.js b/src/util/dataTable.js index 3760d7d45f..1eba91fa02 100644 --- a/src/util/dataTable.js +++ b/src/util/dataTable.js @@ -42,3 +42,42 @@ export const getRowClickAction = ( return null } + +export const hasActiveDataTableFilters = ({ + dataFilters, + globalSearch, + selectionFilter, + showOnlyFeaturesInView, +}) => + Object.keys(dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 || + !!showOnlyFeaturesInView + +export const buildFeatureIndex = (data) => { + const index = new Map() + data?.forEach((f) => { + const id = f.properties?.id ?? f.id + if (id != null) { + index.set(id, f) + } + }) + return index +} + +export const getPanelHeights = ({ + windowHeight, + dataTableHeight, + isCollapsed, + headerHeight, + toolbarHeight, + controlsHeight, +}) => { + const maxHeight = windowHeight - headerHeight - toolbarHeight + const tableHeight = Math.min(dataTableHeight, maxHeight) + return { + maxHeight, + collapsedHeight: controlsHeight, + displayHeight: isCollapsed ? controlsHeight : tableHeight, + } +} diff --git a/src/util/filter.js b/src/util/filter.js index f4793a2964..7189e2568c 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,4 +1,7 @@ -import { SENTINEL_ANY_VALUE } from '../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../constants/dataTable.js' // Filters an array of object with a set of filters export const filterData = (data, filters) => { @@ -19,11 +22,13 @@ export const filterData = (data, filters) => { if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value - const stringValue = value == null ? '' : String(value) + const stringValue = + value == null ? SENTINEL_NO_VALUE : String(value) return ( filter.length === 0 || filter.includes(stringValue) || - (stringValue !== '' && filter.includes(SENTINEL_ANY_VALUE)) + (stringValue !== SENTINEL_NO_VALUE && + filter.includes(SENTINEL_ANY_VALUE)) ) } diff --git a/src/util/filterInput.js b/src/util/filterInput.js index dc86ddd3ec..db972db347 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { TYPE_NUMBER } from '../constants/dataTable.js' import { numericFilter } from './filter.js' const POPOVER_ROW_NON_LABEL_WIDTH = 56 @@ -35,7 +36,7 @@ export const getFilteredOptions = ({ if (!trimmedSearch) { return realOptions } - if (type === 'number') { + if (type === TYPE_NUMBER) { return realOptions.filter(({ value }) => numericFilter(Number(value), trimmedSearch) ) @@ -66,3 +67,17 @@ export const getPopoverWidth = (maxLabelWidth) => ), MAX_POPOVER_WIDTH ) + +export const hasMatchingOptionLabel = (options, resolveLabel, normalizedText) => + options.some( + ({ value }) => resolveLabel(value).toLowerCase() === normalizedText + ) + +export const getCyclicIndex = (current, total, delta) => + total ? (current + delta + total) % total : -1 + +export const toOptionIndex = (highlightedIndex, showCustomFilterRow) => + showCustomFilterRow ? highlightedIndex - 1 : highlightedIndex + +export const toHighlightedIndex = (optionIndex, showCustomFilterRow) => + showCustomFilterRow ? optionIndex + 1 : optionIndex diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index fba5004a08..c8dd4dbcbc 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -1,3 +1,6 @@ +import { arrayMoveImmutable } from 'array-move' +import { SENTINEL_NO_VALUE, TYPE_NUMBER } from '../constants/dataTable.js' + const CHECKBOX_COLUMN_WIDTH = 76 export const getDefaultVisibleKeys = (headers) => @@ -111,3 +114,66 @@ export const getPinnedLeftOffsets = ( }) return offsets } + +// Expensive: scans every row once per column +export const getColumnDistinctValues = (headers, data) => { + if (!headers?.length || !data?.length) { + return null + } + + const result = {} + headers.forEach(({ dataKey, type }) => { + const seen = new Set() + for (const item of data) { + const val = item[dataKey] + seen.add( + val === undefined || val === null || val === SENTINEL_NO_VALUE + ? SENTINEL_NO_VALUE + : String(val) + ) + } + + if (seen.size > 0) { + result[dataKey] = { values: Array.from(seen), type } + } + }) + + return result +} + +export const buildRowCells = (item, headers) => + headers.map(({ dataKey, roundFn, type }) => { + const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] + return { + dataKey, + value: type === TYPE_NUMBER && isNaN(value) ? null : value, + align: type === TYPE_NUMBER ? 'right' : 'left', + itemId: item.id, + } + }) + +export const filterHeadersByName = (headers, search) => { + const normalizedSearch = search.trim().toLowerCase() + return headers.filter((h) => + h.name.toLowerCase().includes(normalizedSearch) + ) +} + +export const reorderHeaderKeys = ( + orderedHeaders, + activeDataKey, + overDataKey +) => { + const oldIndex = orderedHeaders.findIndex( + (h) => h.dataKey === activeDataKey + ) + const newIndex = orderedHeaders.findIndex((h) => h.dataKey === overDataKey) + + if (oldIndex === -1 || newIndex === -1) { + return null + } + + return arrayMoveImmutable(orderedHeaders, oldIndex, newIndex).map( + (h) => h.dataKey + ) +} diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js new file mode 100644 index 0000000000..0b32fdc636 --- /dev/null +++ b/src/util/tableHeaders.js @@ -0,0 +1,332 @@ +import i18n from '@dhis2/d2-i18n' +import { + RENDERER_COLOR, + RENDERER_ICON, + TYPE_NUMBER, + TYPE_STRING, + TYPE_DATE, +} from '../constants/dataTable.js' +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, +} from '../constants/layers.js' +import { numberValueTypes } from '../constants/valueTypes.js' +import { hasClasses } from './earthEngine.js' +import { getGeojsonDisplayData } from './geojson.js' +import { getRoundToPrecisionFn, getPrecision } from './numbers.js' +import { isValidUid } from './uid.js' + +export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE } + +const NAME = 'name' +const ID = 'id' +const VALUE = 'rawValue' +const LEGEND = 'legend' +const RANGE = 'range' +const LEVEL = 'level' +const PARENT_NAME = 'parentName' +const TYPE = 'type' +const COLOR = 'color' +const GROUP = 'group' +const ICON = 'iconUrl' +const OUNAME = 'ouname' +const OUBOUNDARY = 'ouBoundary' +const EVENTDATE = 'eventdate' + +export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES' + +const defaultFieldsMap = () => ({ + [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, + [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, + [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, + [PARENT_NAME]: { + name: i18n.t('Parent'), + dataKey: PARENT_NAME, + type: TYPE_STRING, + }, + [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, + [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, + [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, + [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, + [OUBOUNDARY]: { + name: i18n.t('Org unit boundary'), + dataKey: OUBOUNDARY, + type: TYPE_STRING, + }, + [EVENTDATE]: { + name: i18n.t('Event time'), + dataKey: EVENTDATE, + type: TYPE_DATE, + renderer: 'formatTime...', + }, + [COLOR]: { + name: i18n.t('Color'), + dataKey: COLOR, + type: TYPE_STRING, + renderer: RENDERER_COLOR, + }, + [GROUP]: { name: i18n.t('Group'), dataKey: GROUP, type: TYPE_STRING }, + [ICON]: { + name: i18n.t('Icon'), + dataKey: ICON, + type: TYPE_STRING, + renderer: RENDERER_ICON, + }, +}) + +const getStyleHeaders = ({ + hasLegend, + hasRange, + hasGroup, + hasColor, + hasIcon, +}) => { + const headers = [] + if (hasLegend) { + headers.push(defaultFieldsMap()[LEGEND]) + } + if (hasRange) { + headers.push(defaultFieldsMap()[RANGE]) + } + if (hasGroup) { + headers.push(defaultFieldsMap()[GROUP]) + } + if (hasColor) { + headers.push(defaultFieldsMap()[COLOR]) + } + if (hasIcon) { + headers.push(defaultFieldsMap()[ICON]) + } + return headers +} + +const getThematicHeaders = () => + [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat( + getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true }) + ) + +const getMultiPeriodThematicHeaders = ({ + isTimelineThematic, + externalPeriod, + periods, +}) => { + const headers = isTimelineThematic + ? getThematicHeaders().map((header) => + [VALUE, LEGEND, RANGE, COLOR].includes(header.dataKey) + ? { + ...header, + name: `${header.name} (${ + externalPeriod?.name ?? i18n.t('Current period') + })`, + } + : header + ) + : getOrgUnitHeaders() + + const otherPeriods = isTimelineThematic + ? (periods ?? []).filter((p) => p.id !== externalPeriod?.id) + : periods ?? [] + + otherPeriods.forEach((period) => { + headers.push({ + name: i18n.t('Value ({{period}})', { period: period.name }), + dataKey: `period_${period.id}_rawValue`, + type: TYPE_NUMBER, + defaultHidden: true, + }) + }) + + return headers +} + +const getEventHeaders = ({ + layerHeaders = [], + styleDataItem, + countEventsOutsideOrgUnits, +}) => { + const fields = [OUNAME, ID, EVENTDATE].map( + (field) => defaultFieldsMap()[field] + ) + + if (countEventsOutsideOrgUnits) { + fields.push(defaultFieldsMap()[OUBOUNDARY]) + } + + const customFields = layerHeaders + .filter(({ name }) => isValidUid(name)) + .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ + name, + dataKey, + type: + !optionSet && numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + optionSet: optionSet || null, + })) + + customFields.push( + defaultFieldsMap()[TYPE], + ...getStyleHeaders({ + hasLegend: !!styleDataItem, + hasRange: !!styleDataItem, + hasColor: !!styleDataItem, + }) + ) + + return fields.concat(customFields) +} + +const getOrgUnitStyleHeaders = (data) => { + let hasGroup = false + let hasColor = false + let hasIcon = false + + for (const d of data ?? []) { + hasGroup ||= d.group != null + hasColor ||= d.color != null + hasIcon ||= d.iconUrl != null + + if (hasGroup && hasColor && hasIcon) { + break + } + } + + return getStyleHeaders({ hasGroup, hasColor, hasIcon }) +} + +// Org unit and facility headers share the same shape +const getFixedFieldsWithOrgUnitStyle = (fields, data) => + fields + .map((field) => defaultFieldsMap()[field]) + .concat(getOrgUnitStyleHeaders(data)) + +const getOrgUnitHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, LEVEL, PARENT_NAME, TYPE], data) + +const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { + const fields = [ID].map((field) => defaultFieldsMap()[field]) + + const customFields = layerHeaders + .filter(({ dataKey }) => isValidUid(dataKey)) + .map(({ name, dataKey, valueType }) => ({ + name, + dataKey, + type: numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + })) + + customFields.push(...getStyleHeaders({ hasColor: true })) + + return fields.concat(customFields) +} + +const getFacilityHeaders = (data) => + getFixedFieldsWithOrgUnitStyle([NAME, ID, TYPE], data) + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { + const { title, items } = legend + + let customFields = [] + + if (hasClasses(aggregationType) && items) { + customFields = items.map(({ value, name }) => ({ + name, + dataKey: String(value), + roundFn: getRoundToPrecisionFn(2), + type: TYPE_NUMBER, + })) + } else if (Array.isArray(aggregationType) && aggregationType.length) { + customFields = aggregationType.map((type) => { + let roundFn = null + if (data?.length) { + const precision = getPrecision(data.map((d) => d[type])) + roundFn = getRoundToPrecisionFn(precision) + } + return { + name: toTitleCase(`${type} ${title}`), + dataKey: type, + roundFn, + type: TYPE_NUMBER, + } + }) + } + + return [NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(customFields) +} + +const getGeoJsonUrlHeaders = (firstDataItem) => + getGeojsonDisplayData(firstDataItem).map((header) => + header.dataKey === COLOR ? defaultFieldsMap()[COLOR] : header + ) + +export const getHeadersForLayer = (layerType, ctx) => { + switch (layerType) { + case THEMATIC_LAYER: + return { + headers: ctx.isMultiPeriodThematic + ? getMultiPeriodThematicHeaders({ + isTimelineThematic: ctx.isTimelineThematic, + externalPeriod: ctx.externalPeriod, + periods: ctx.periods, + }) + : getThematicHeaders(), + } + case EVENT_LAYER: + return { + headers: getEventHeaders({ + layerHeaders: ctx.layerHeaders, + styleDataItem: ctx.styleDataItem, + countEventsOutsideOrgUnits: ctx.countEventsOutsideOrgUnits, + }), + } + case ORG_UNIT_LAYER: + return { headers: getOrgUnitHeaders(ctx.data) } + case TRACKED_ENTITY_LAYER: + return { + headers: getTrackedEntityHeaders({ + layerHeaders: ctx.layerHeaders, + }), + } + case EARTH_ENGINE_LAYER: + return { + headers: getEarthEngineHeaders({ + aggregationType: ctx.aggregationType, + legend: ctx.legend, + data: ctx.data, + }), + } + case FACILITY_LAYER: + return { headers: getFacilityHeaders(ctx.data) } + case GEOJSON_URL_LAYER: { + // Unlike the other cases, this reads the raw layer data + // rather than dataWithAggregations + const rawData = ctx.rawData ?? [] + const isHomogenous = rawData.every( + (feature) => feature.geometry.type === rawData[0]?.geometry.type + ) + if (!isHomogenous) { + return { errorCode: ERROR_NON_HOMOGENOUS_FEATURES } + } + return { headers: getGeoJsonUrlHeaders(rawData[0]) } + } + default: + return { headers: null } + } +} diff --git a/src/util/tableRows.js b/src/util/tableRows.js new file mode 100644 index 0000000000..bef21fa009 --- /dev/null +++ b/src/util/tableRows.js @@ -0,0 +1,108 @@ +import { GEOJSON_URL_LAYER } from '../constants/layers.js' +import { isFeatureInBounds } from './geojson.js' +import { formatRangeWithSeparator } from './numbers.js' + +export const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' +export const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' + +export const buildTableData = ( + layerType, + { + data, + dataWithoutCoords, + serverCluster, + showOnlyFeaturesInView, + mapBounds, + aggregations, + isStyledEvent, + isMultiPeriodThematic, + isTimelineThematic, + legend, + valuesByPeriod, + externalPeriod, + periods, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces, + } +) => { + if (serverCluster) { + return { errorCode: ERROR_SERVER_CLUSTER } + } + + const allData = dataWithoutCoords?.length + ? [...(data || []), ...dataWithoutCoords] + : data + + if (!allData?.length) { + return { errorCode: ERROR_NO_VALID_DATA } + } + + const inViewData = showOnlyFeaturesInView + ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) + : allData + + if (layerType === GEOJSON_URL_LAYER) { + return { data: inViewData.map((d) => ({ ...d.properties })) } + } + + const rows = inViewData + .filter((d) => !d.properties.hasAdditionalGeometry) + .map((d, index) => { + const properties = d.properties || d + + if (isStyledEvent) { + const legendItem = legend?.items?.[properties.colorGroup] + return { + ...properties, + legend: legendItem?.name, + range: + legendItem && 'startValue' in legendItem + ? formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : undefined, + ...aggregations[d.id], + index, + } + } + + if (!isMultiPeriodThematic) { + return { + ...properties, + ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set + index, + } + } + + const orgUnitId = properties.id + const currentPeriodItem = isTimelineThematic + ? valuesByPeriod?.[externalPeriod?.id]?.[orgUnitId] + : null + const otherPeriodValues = {} + ;(periods ?? []).forEach((period) => { + if (isTimelineThematic && period.id === externalPeriod?.id) { + return + } + otherPeriodValues[`period_${period.id}_rawValue`] = + valuesByPeriod?.[period.id]?.[orgUnitId]?.value ?? null + }) + + return { + ...properties, + ...(currentPeriodItem && { + rawValue: currentPeriodItem.value, + color: currentPeriodItem.color, + legend: currentPeriodItem.legend, + range: currentPeriodItem.range, + }), + ...otherPeriodValues, + ...aggregations[d.id], + index, + } + }) + + return { data: rows } +} diff --git a/src/util/tableSort.js b/src/util/tableSort.js index d101d6caa1..5c1f9d3999 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -2,6 +2,7 @@ import { SENTINEL_NO_VALUE, SENTINEL_SELECTED_ROW, SORT_ASCENDING, + TYPE_NUMBER, } from '../constants/dataTable.js' import { parseRange } from './legend.js' @@ -32,7 +33,7 @@ export const compareColumnOptionValues = ( return compareRangeValues(a, b, direction) } const comparison = - type === 'number' ? Number(a) - Number(b) : compareStrings(a, b) + type === TYPE_NUMBER ? Number(a) - Number(b) : compareStrings(a, b) return direction === SORT_ASCENDING ? comparison : -comparison } From de31b9f19673e2d31d934d0acb84731e2b4698ec Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 11:10:52 +0200 Subject: [PATCH 17/47] chore: sonarqube issue --- src/util/tableColumns.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js index c8dd4dbcbc..5579d28d3b 100644 --- a/src/util/tableColumns.js +++ b/src/util/tableColumns.js @@ -146,7 +146,10 @@ export const buildRowCells = (item, headers) => const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] return { dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, + value: + type === TYPE_NUMBER && Number.isNaN(Number(value)) + ? null + : value, align: type === TYPE_NUMBER ? 'right' : 'left', itemId: item.id, } From 25dbb5907c79061fd6803f4fead317e0eedb3bcc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 11:23:31 +0200 Subject: [PATCH 18/47] chore: PR clean-up --- .../datatable/__tests__/ColumnPickerControl.spec.jsx | 4 ---- .../datatable/__tests__/useTableData.spec.jsx | 5 ----- src/loaders/__tests__/geoJsonUrlLoader.spec.js | 3 --- src/loaders/geoJsonUrlLoader.js | 11 ++--------- src/loaders/trackedEntityLoader.js | 8 ++------ src/util/tableSort.js | 3 +-- 6 files changed, 5 insertions(+), 29 deletions(-) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index 6e16c5f451..9df0ad1b3c 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -357,10 +357,6 @@ describe('ColumnPicker search', () => { }) describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => { - // Period columns exist as regular headers for every available period, - // but start out unchecked - same mechanism as any other column, no - // dedicated "add period" UI. A defaultHidden header exercises that - // exact path without needing a real thematic/timeline layer fixture. const headersWithHiddenColumn = [ ...headers, { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index f55ca6520b..1888265add 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -363,9 +363,6 @@ describe('useTableData headers', () => { }) test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => { - // Period columns exist for every period regardless of any saved - // config - they're just hidden by default (defaultHidden), same - // mechanism as any other column, controlled via the column picker. const store = { aggregations: {}, ui: { @@ -408,8 +405,6 @@ describe('useTableData headers', () => { } ) const { headers, rows } = result.current - // The active period (February 2023) is the Value/Legend/Range/Color - // columns, not a separate period_* column. expect(headers).not.toContainEqual( expect.objectContaining({ dataKey: 'period_202302_rawValue' }) ) diff --git a/src/loaders/__tests__/geoJsonUrlLoader.spec.js b/src/loaders/__tests__/geoJsonUrlLoader.spec.js index e3d655d858..8eac7f2bb2 100644 --- a/src/loaders/__tests__/geoJsonUrlLoader.spec.js +++ b/src/loaders/__tests__/geoJsonUrlLoader.spec.js @@ -51,9 +51,6 @@ describe('stampFeatureColors', () => { }) it('never overwrites a feature that already has its own color', () => { - // maps-gl's colorExpr prefers a feature's own properties.color over - // the layer's uniform style color, so a user-uploaded file with its - // own per-feature colors must keep rendering with them. const features = [ { geometry: { type: 'Point' }, diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index f601e7bae3..af1721bed3 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -7,12 +7,8 @@ import { GEO_TYPE_POLYGON, } from '../util/geojson.js' -// features of different (non-Multi-normalized) geometry types get their -// own color, matching the map legend's own per-type color - never -// overwrites a feature's own pre-existing color (maps-gl's colorExpr -// already prefers a per-feature properties.color over the layer's -// uniform style color, so a feature that already has one is rendered -// with it, and the data table should reflect the same real color). +// Stamps each feature with its geometry type's legend color, unless the feature already has its own +// (maps-gl's colorExpr prefers a per-feature color, so the data table must match). export const stampFeatureColors = (features, legendItemsByType) => features.map((f) => { if (f.properties.color != null) { @@ -141,9 +137,6 @@ const geoJsonUrlLoader = async ({ legendItemsByType[type] = legendItem }) - // A per-geometry-type color, for the data table's Color column - - // features of different types in the same file get different - // colors here, matching what the map legend already shows per type. data = stampFeatureColors(featureCollection, legendItemsByType) } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 7373e0f3c8..aa612e7fe6 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -106,8 +106,6 @@ export const getAttributeProperties = (attributes) => (attributes ?? []).map(({ attribute, value }) => [attribute, value]) ) -// One header per unique attribute uid seen across all instances - not every -// instance necessarily has a value for every attribute. export const getAttributeHeaders = (instances) => { const headersByAttribute = new Map() instances.forEach(({ attributes }) => { @@ -124,10 +122,8 @@ export const getAttributeHeaders = (instances) => { return [...headersByAttribute.values()] } -// The main tracked entity marker's own color is currently fixed for every -// instance (no per-instance classification yet, unlike thematic/event) - -// still stamped here so the data table's Color column has real data ready -// to become meaningful once that changes. +// The main tracked entity marker's own color is currently fixed still +// stamped here for when data table's Color column has real data export const toGeoJson = (instances, color) => instances.map(({ id, geometry, attributes }) => ({ type: GEO_TYPE_FEATURE, diff --git a/src/util/tableSort.js b/src/util/tableSort.js index 5c1f9d3999..ac53e00f6b 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -64,8 +64,7 @@ export const compareFieldValues = ( bVal, { sortField, sortDirection } ) => { - // All missing values (undefined, or null - e.g. a period column with no - // data for a given org unit) should be sorted to the end + // All missing values should be sorted to the end if (isNoValue(aVal) && isNoValue(bVal)) { return 0 } From 631af94cc6c16e63dc33940b2534576c501773eb Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 11:51:58 +0200 Subject: [PATCH 19/47] fix datatable support geometry+multigeometry mix --- src/util/__tests__/tableHeaders.spec.js | 10 ++++++++++ src/util/tableHeaders.js | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index c3e0f0d798..97a4e078e2 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -223,6 +223,16 @@ describe('getHeadersForLayer - geoJsonUrl', () => { const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) expect(result).toEqual({ errorCode: ERROR_NON_HOMOGENOUS_FEATURES }) }) + + test('a Polygon/MultiPolygon mix is homogenous (matches the loader’s own Multi-normalization)', () => { + const rawData = [ + { geometry: { type: 'Polygon' }, properties: { name: 'A' } }, + { geometry: { type: 'MultiPolygon' }, properties: { name: 'B' } }, + ] + const result = getHeadersForLayer(GEOJSON_URL_LAYER, { rawData }) + expect(result.errorCode).toBeUndefined() + expect(dataKeys(result)).toEqual(expect.arrayContaining(['name'])) + }) }) describe('getHeadersForLayer - unknown layer type', () => { diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 0b32fdc636..088c87fa0c 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -318,8 +318,11 @@ export const getHeadersForLayer = (layerType, ctx) => { // Unlike the other cases, this reads the raw layer data // rather than dataWithAggregations const rawData = ctx.rawData ?? [] + const nonMultiType = (type) => type.replaceAll('Multi', '') const isHomogenous = rawData.every( - (feature) => feature.geometry.type === rawData[0]?.geometry.type + (feature) => + nonMultiType(feature.geometry.type) === + nonMultiType(rawData[0]?.geometry.type ?? '') ) if (!isHomogenous) { return { errorCode: ERROR_NON_HOMOGENOUS_FEATURES } From a84e847cb3d439e38a6e1c57941890810a12d058 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 11:57:33 +0200 Subject: [PATCH 20/47] fix: coherce numeric valueTypes in TE layer for datatable --- .../__tests__/trackedEntityLoader.spec.js | 18 ++++++++++++++++++ src/loaders/trackedEntityLoader.js | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 4f322ca4e4..a9a69cceea 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -25,6 +25,24 @@ describe('getAttributeProperties', () => { expect(getAttributeProperties(undefined)).toEqual({}) expect(getAttributeProperties([])).toEqual({}) }) + + it('coerces a numeric-valueType attribute value to a real number', () => { + const attributes = [ + { attribute: 'ageUid', value: '34', valueType: 'INTEGER' }, + { attribute: 'nameUid', value: 'Gabrielle', valueType: 'TEXT' }, + ] + expect(getAttributeProperties(attributes)).toEqual({ + ageUid: 34, + nameUid: 'Gabrielle', + }) + }) + + it('leaves a numeric-valueType value with no data as undefined, not NaN', () => { + const attributes = [ + { attribute: 'ageUid', value: '', valueType: 'INTEGER' }, + ] + expect(getAttributeProperties(attributes).ageUid).toBeUndefined() + }) }) describe('getAttributeHeaders', () => { diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index aa612e7fe6..b50e5b8e51 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -8,6 +8,7 @@ import { TEI_RELATIONSHIP_LINE_COLOR, } from '../constants/layers.js' import { getProgramStatuses } from '../constants/programStatuses.js' +import { numberValueTypes } from '../constants/valueTypes.js' import { getOrgUnitsFromRows } from '../util/analytics.js' import { parseJsonConfig } from '../util/config.js' import { @@ -17,6 +18,7 @@ import { GEO_TYPE_LINE, GEO_TYPE_FEATURE, } from '../util/geojson.js' +import { parseWithSeparator } from '../util/numbers.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' @@ -103,7 +105,12 @@ const TRACKED_ENTITY_TYPES_QUERY = { export const getAttributeProperties = (attributes) => Object.fromEntries( - (attributes ?? []).map(({ attribute, value }) => [attribute, value]) + (attributes ?? []).map(({ attribute, value, valueType }) => [ + attribute, + numberValueTypes.includes(valueType) + ? parseWithSeparator(value) + : value, + ]) ) export const getAttributeHeaders = (instances) => { From 699af741fea910d5e18ef3d17c17b3239f9306fe Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 12:04:01 +0200 Subject: [PATCH 21/47] fix: no drilling and profile for TE layer --- src/components/datatable/TableContextMenu.jsx | 5 ++- .../__tests__/TableContextMenu.spec.jsx | 37 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index dac6bef021..eb69c3e55d 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -18,6 +18,7 @@ import { EVENT_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, } from '../../constants/layers.js' import { getGeojsonFeatureProfile } from '../../util/geojson.js' import { drillUpDown } from '../../util/map.js' @@ -29,6 +30,7 @@ const UNDRILLABLE_LAYERS = new Set([ FACILITY_LAYER, EVENT_LAYER, GEOJSON_URL_LAYER, + TRACKED_ENTITY_LAYER, ]) const TableContextMenu = ({ @@ -63,7 +65,8 @@ const TableContextMenu = ({ const canDrill = !UNDRILLABLE_LAYERS.has(layerType) - const canViewProfile = id && layerType !== EVENT_LAYER + const canViewProfile = + id && layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER return ( <> diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx index 71f81c655e..0384092cdc 100644 --- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -2,8 +2,14 @@ import { render, fireEvent, screen } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' -import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' -import { FACILITY_LAYER } from '../../../constants/layers.js' +import { + FEATURE_HIGHLIGHT, + ORGANISATION_UNIT_PROFILE_SET, +} from '../../../constants/actionTypes.js' +import { + FACILITY_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../../constants/layers.js' import TableContextMenu from '../TableContextMenu.jsx' jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ @@ -37,6 +43,33 @@ const renderMenu = (props) => { return { ...result, store } } +describe('TableContextMenu — view profile menu item', () => { + test('is not offered for a Tracked Entity row (id is a TEI uid, not an org unit id)', () => { + renderMenu({ + layer: { id: 'layer1', layer: TRACKED_ENTITY_LAYER }, + contextMenu: { x: 10, y: 10, featureProps: { id: 'tei1' } }, + }) + expect( + screen.queryByTestId('data-table-context-menu-view-profile') + ).not.toBeInTheDocument() + }) + + test('dispatches setOrgUnitProfile with the row id for a layer type that supports it', () => { + const { store } = renderMenu({ + contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } }, + }) + fireEvent.click( + screen + .getByTestId('data-table-context-menu-view-profile') + .querySelector('a') + ) + expect(store.getActions()).toContainEqual({ + type: ORGANISATION_UNIT_PROFILE_SET, + payload: 'ou1', + }) + }) +}) + describe('TableContextMenu — zoom to filtered features', () => { test('is disabled when no filter is active (filteredIds is null)', () => { renderMenu({ filteredIds: null }) From afb4b10801c277e5780fc8754c170458fef8a339 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 12:15:03 +0200 Subject: [PATCH 22/47] fix: cancel on resize issues --- src/components/datatable/BottomPanel.jsx | 5 +++ .../controls/ResizeHandleControl.jsx | 29 +++++++++++--- .../__tests__/ResizeHandleControl.spec.jsx | 39 ++++++++++++++++++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 64d3f84dfc..4399c78c4c 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -123,6 +123,10 @@ const BottomPanel = () => { [dispatch] ) + const onResizeCancel = useCallback(() => { + isDraggingRef.current = false + }, []) + const onCountChange = useCallback((total, filtered) => { setTotalCount(total) setFilteredCount(filtered) @@ -224,6 +228,7 @@ const BottomPanel = () => { onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd} + onResizeCancel={onResizeCancel} /> { @@ -47,10 +48,7 @@ const ResizeHandleControl = ({ onResize?.(getHeight(evt.clientY)) } - const onPointerUp = (evt) => { - if (!isDraggingRef.current) { - return - } + const endDrag = (evt) => { isDraggingRef.current = false evt.currentTarget.releasePointerCapture(evt.pointerId) if (!hasMovedRef.current) { @@ -58,7 +56,25 @@ const ResizeHandleControl = ({ } evt.currentTarget.style.removeProperty('cursor') document.body.style.removeProperty('cursor') - onResizeEnd?.(getHeight(evt.clientY)) + } + + const onPointerUp = (evt) => { + if (!isDraggingRef.current) { + return + } + const shouldCommit = hasMovedRef.current + endDrag(evt) + if (shouldCommit) { + onResizeEnd?.(getHeight(evt.clientY)) + } + } + + const onPointerCancel = (evt) => { + if (!isDraggingRef.current) { + return + } + endDrag(evt) + onResizeCancel?.() } // In case the handle/panel unmounts mid-drag @@ -77,7 +93,7 @@ const ResizeHandleControl = ({ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} - onPointerCancel={onPointerUp} + onPointerCancel={onPointerCancel} > @@ -90,6 +106,7 @@ ResizeHandleControl.propTypes = { maxHeight: PropTypes.number.isRequired, minHeight: PropTypes.number, onResize: PropTypes.func, + onResizeCancel: PropTypes.func, onResizeEnd: PropTypes.func, onResizeStart: PropTypes.func, } diff --git a/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx index 3101e25d59..86720223ba 100644 --- a/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx +++ b/src/components/datatable/controls/__tests__/ResizeHandleControl.spec.jsx @@ -20,12 +20,14 @@ const renderHandle = (props = {}) => { const onResize = jest.fn() const onResizeStart = jest.fn() const onResizeEnd = jest.fn() + const onResizeCancel = jest.fn() const { container } = render( ) @@ -34,6 +36,7 @@ const renderHandle = (props = {}) => { onResize, onResizeStart, onResizeEnd, + onResizeCancel, } } @@ -62,7 +65,8 @@ describe('ResizeHandleControl', () => { }) it('resizes once the pointer moves past the drag threshold', () => { - const { handle, onResize, onResizeStart, onResizeEnd } = renderHandle() + const { handle, onResize, onResizeStart, onResizeEnd, onResizeCancel } = + renderHandle() firePointerEvent('pointerDown', handle, { clientY: 300 }) firePointerEvent('pointerMove', handle, { clientY: 280 }) @@ -71,5 +75,38 @@ describe('ResizeHandleControl', () => { expect(onResizeStart).toHaveBeenCalledTimes(1) expect(onResize).toHaveBeenCalled() expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() + }) + + it('resets the drag state on cancel without committing a resize', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + firePointerEvent('pointerDown', handle, { clientY: 500 }) + firePointerEvent('pointerMove', handle, { clientY: 400 }) + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeCancel).toHaveBeenCalledTimes(1) + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + it('ignores a stray pointercancel with no active drag', () => { + const { handle, onResizeCancel, onResizeEnd } = renderHandle() + + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeCancel).not.toHaveBeenCalled() + expect(onResizeEnd).not.toHaveBeenCalled() + }) + + it('ignores a pointer up/cancel that arrives after the drag already ended', () => { + const { handle, onResizeEnd, onResizeCancel } = renderHandle() + + firePointerEvent('pointerDown', handle, { clientY: 500 }) + firePointerEvent('pointerMove', handle, { clientY: 400 }) + firePointerEvent('pointerUp', handle, { clientY: 400 }) + firePointerEvent('pointerCancel', handle, { clientY: 0 }) + + expect(onResizeEnd).toHaveBeenCalledTimes(1) + expect(onResizeCancel).not.toHaveBeenCalled() }) }) From ed39fd91f8d45d563d8a3f834c16726ce71b4e37 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 12:48:18 +0200 Subject: [PATCH 23/47] fix: raceediting filters/columns while an event layer's data is still extending --- src/reducers/__tests__/map.spec.js | 50 ++++++++++++++++++++++++++++++ src/reducers/map.js | 4 +++ 2 files changed, 54 insertions(+) diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 1c70810eab..8164b8551f 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -334,6 +334,56 @@ describe('map reducer - per-layer delegation', () => { }) expect(result.mapViews[1]).toBe(other) }) + + it("keeps the live dataTableColumnConfig/dataFilters instead of an async loader payload's stale snapshot", () => { + const state = { + ...defaultState, + mapViews: [ + { + id: 'layer1', + name: 'Old', + dataTableColumnConfig: { visibleKeys: ['name'] }, + dataFilters: { name: 'foo' }, + }, + ], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + // Stale: captured before the user's edits above + dataTableColumnConfig: undefined, + dataFilters: undefined, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['name'], + }) + expect(result.mapViews[0].dataFilters).toEqual({ name: 'foo' }) + }) + + it("uses the payload's dataTableColumnConfig/dataFilters when the layer has none live yet (first load)", () => { + const state = { + ...defaultState, + mapViews: [{ id: 'layer1', name: 'Old' }], + } + + const result = map(state, { + type: types.LAYER_UPDATE, + payload: { + id: 'layer1', + name: 'New', + dataTableColumnConfig: { visibleKeys: ['id'] }, + }, + }) + + expect(result.mapViews[0].dataTableColumnConfig).toEqual({ + visibleKeys: ['id'], + }) + }) }) describe('LAYER_EDIT', () => { diff --git a/src/reducers/map.js b/src/reducers/map.js index d32818a36d..b1c69de918 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -90,6 +90,10 @@ const layer = (state, action) => { return { ...action.payload, + dataTableColumnConfig: + state.dataTableColumnConfig ?? + action.payload.dataTableColumnConfig, + dataFilters: state.dataFilters ?? action.payload.dataFilters, } case types.LAYER_CHANGE_OPACITY: From e4b425913a72daf6b23e4ddf48860ccfb4385e31 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 13:58:54 +0200 Subject: [PATCH 24/47] fix: crash + dead-code bug in parseLayerConfig --- src/components/loaders/useLoaderAlerts.js | 14 +++++ src/constants/alerts.js | 2 + src/loaders/externalLoader.js | 12 ++++- src/loaders/geoJsonUrlLoader.js | 12 ++++- src/util/__tests__/external.spec.js | 66 +++++++++++++++++++++++ src/util/external.js | 11 ++-- 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/components/loaders/useLoaderAlerts.js b/src/components/loaders/useLoaderAlerts.js index 949bbf936f..da9b6fc4e3 100644 --- a/src/components/loaders/useLoaderAlerts.js +++ b/src/components/loaders/useLoaderAlerts.js @@ -7,6 +7,7 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, WARNING_OU_BOUNDARIES_FETCH_FAILED, + WARNING_EXTERNAL_LAYER_NOT_FOUND, ERROR_CRITICAL, CUSTOM_ALERT, } from '../../constants/alerts.js' @@ -44,6 +45,11 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { onHidden: loaderAlertAction, }) + const externalLayerNotFoundAlert = useAlert(ALERT_MESSAGE_DYNAMIC, { + warning: true, + onHidden: loaderAlertAction, + }) + const showAlerts = (alerts) => { alerts.forEach(({ message: msg, code, warning, critical }) => { switch (code) { @@ -84,6 +90,14 @@ function useLoaderAlerts(loaderAlertAction = Function.prototype) { }) break } + case WARNING_EXTERNAL_LAYER_NOT_FOUND: { + externalLayerNotFoundAlert.show({ + msg: `${msg}: ${i18n.t( + 'External layer definition not found, showing last known settings' + )}`, + }) + break + } case ERROR_CRITICAL: { errorAlert.show({ msg: `${i18n.t('Error')}: ${msg}` }) break diff --git a/src/constants/alerts.js b/src/constants/alerts.js index 682851742b..9280206e49 100644 --- a/src/constants/alerts.js +++ b/src/constants/alerts.js @@ -14,5 +14,7 @@ export const WARNING_NO_OU_COORD = 'WARNING_NO_OU_COORD' export const WARNING_NO_GEOMETRY_COORD = 'WARNING_NO_GEOMETRY_COORD' export const WARNING_OU_BOUNDARIES_FETCH_FAILED = 'WARNING_OU_BOUNDARIES_FETCH_FAILED' +export const WARNING_EXTERNAL_LAYER_NOT_FOUND = + 'WARNING_EXTERNAL_LAYER_NOT_FOUND' export const ERROR_CRITICAL = 'ERROR_CRITICAL' export const CUSTOM_ALERT = 'CUSTOM_ALERT' diff --git a/src/loaders/externalLoader.js b/src/loaders/externalLoader.js index 0e031c9dde..1cac31e94d 100644 --- a/src/loaders/externalLoader.js +++ b/src/loaders/externalLoader.js @@ -1,3 +1,4 @@ +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { EXTERNAL_LAYER } from '../constants/layers.js' import { parseLayerConfig } from '../util/external.js' import { getPredefinedLegendItems } from '../util/legend.js' @@ -5,9 +6,17 @@ import { LEGEND_SET_QUERY } from '../util/requests.js' const externalLoader = async ({ config: layer, engine }) => { let config + const alerts = [] if (typeof layer.config === 'string') { // External layer is loaded in analytical object - config = await parseLayerConfig(layer.config, engine) + const parsed = await parseLayerConfig(layer.config, engine) + config = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } } else { config = { ...layer.config } } @@ -37,6 +46,7 @@ const externalLoader = async ({ config: layer, engine }) => { isLoaded: true, isLoading: false, isExpanded: true, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index af1721bed3..ee372c35c1 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { WARNING_EXTERNAL_LAYER_NOT_FOUND } from '../constants/alerts.js' import { parseLayerConfig } from '../util/external.js' import { buildGeoJsonFeatures, @@ -72,10 +73,18 @@ const geoJsonUrlLoader = async ({ let newConfig let featureStyle let dataTableColumnConfig + const alerts = [] // keep featureStyle and dataTableColumnConfig properties outside of config while in app if (typeof config === 'string') { // External layer is loaded in analytical object - newConfig = await parseLayerConfig(config, engine) + const parsed = await parseLayerConfig(config, engine) + newConfig = parsed.config + if (parsed.notFound) { + alerts.push({ + code: WARNING_EXTERNAL_LAYER_NOT_FOUND, + message: layer.name, + }) + } featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE dataTableColumnConfig = newConfig.dataTableColumnConfig delete newConfig.featureStyle @@ -153,6 +162,7 @@ const geoJsonUrlLoader = async ({ isLoading: false, isExpanded: true, loadError, + ...(alerts.length ? { alerts } : {}), } } diff --git a/src/util/__tests__/external.spec.js b/src/util/__tests__/external.spec.js index 3089e956e0..30f850280c 100644 --- a/src/util/__tests__/external.spec.js +++ b/src/util/__tests__/external.spec.js @@ -9,6 +9,7 @@ import { import { createExternalBasemapLayer, createExternalOverlayLayer, + parseLayerConfig, } from '../external.js' describe('createExternalBasemapLayer', () => { @@ -254,3 +255,68 @@ describe('createExternalOverlayLayer', () => { }) }) }) + +describe('parseLayerConfig', () => { + test('returns an empty config instead of throwing on malformed JSON', async () => { + await expect(parseLayerConfig('not-valid-json', {})).resolves.toEqual({ + config: {}, + }) + }) + + test('does not throw when the JSON parses to null', async () => { + await expect(parseLayerConfig('null', {})).resolves.toEqual({ + config: null, + }) + }) + + test('returns the local config unchanged when it has no id (nothing to refresh)', async () => { + const config = { url: 'https://path-to-geojson', name: 'Local' } + await expect( + parseLayerConfig(JSON.stringify(config), {}) + ).resolves.toEqual({ config }) + }) + + test('returns a freshly-fetched config on success, carrying featureStyle/dataTableColumnConfig forward', async () => { + const localConfig = { + id: 'ext-1', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + } + const engine = { + query: jest.fn().mockResolvedValue({ + externalLayer: { + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + mapService: 'XYZ', + imageFormat: 'PNG', + }, + }), + } + + const result = await parseLayerConfig( + JSON.stringify(localConfig), + engine + ) + + expect(result.notFound).toBeUndefined() + expect(result.config).toMatchObject({ + id: 'ext-1', + name: 'Fresh name', + url: 'https://fresh-url', + featureStyle: { color: '#ff0000' }, + dataTableColumnConfig: { visibleKeys: ['name'] }, + }) + }) + + test('falls back to the local config and flags notFound when the API fetch fails', async () => { + const localConfig = { id: 'deleted-layer', url: 'https://stale-url' } + const engine = { + query: jest.fn().mockRejectedValue(new Error('404')), + } + + await expect( + parseLayerConfig(JSON.stringify(localConfig), engine) + ).resolves.toEqual({ config: localConfig, notFound: true }) + }) +}) diff --git a/src/util/external.js b/src/util/external.js index 4387d85d44..34cb6cd3be 100644 --- a/src/util/external.js +++ b/src/util/external.js @@ -73,19 +73,18 @@ const createExternalLayerConfig = (model) => { } } -// Parse external layer config returned as a string in ao export const parseLayerConfig = async (layerConfig, engine) => { let config try { config = JSON.parse(layerConfig) } catch (error_) { - return + return { config: {} } } // We could use the config object as stored, but better to // use a fresh layer config from the API - if (config.id) { + if (config?.id) { try { const { externalLayer } = await engine.query( { externalLayer: EXTERNAL_MAP_LAYER_QUERY }, @@ -97,10 +96,12 @@ export const parseLayerConfig = async (layerConfig, engine) => { ) const newConfig = createExternalLayerConfig(externalLayer) newConfig.featureStyle = { ...config.featureStyle } + newConfig.dataTableColumnConfig = config.dataTableColumnConfig + return { config: newConfig } } catch (error_) { - return config + return { config, notFound: true } } } - return config + return { config } } From e48108418a434d115115425262b8b968f42d7d31 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:09:29 +0200 Subject: [PATCH 25/47] fix: header titles alignement --- src/components/datatable/DataTable.jsx | 4 +++- src/components/datatable/styles/DataTable.module.css | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 29f5430638..ad3f1ab295 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -463,7 +463,9 @@ const Table = ({ } > - {name} + + {name} + Date: Thu, 23 Jul 2026 14:44:58 +0200 Subject: [PATCH 26/47] feat: expose spatialSupport from system info for event clustering Co-Authored-By: Claude Sonnet 5 --- src/util/__tests__/app.spec.js | 3 +++ src/util/app.js | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/app.spec.js b/src/util/__tests__/app.spec.js index 305cde18f1..6a88ec9f3b 100644 --- a/src/util/__tests__/app.spec.js +++ b/src/util/__tests__/app.spec.js @@ -114,6 +114,7 @@ describe('utils/app - providerDataTransformation', () => { } const systemInfo = { calendar: 'gregory', + databaseInfo: { spatialSupport: true }, } const cfg = await providerDataTransformation({ @@ -124,6 +125,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBe(true) expect(cfg.basemaps).toHaveLength(12) expect(cfg.nameProperty).toEqual('displayName') expect(cfg.defaultLayerSources).toHaveLength(6) @@ -180,6 +182,7 @@ describe('utils/app - providerDataTransformation', () => { systemInfo, }) + expect(cfg.spatialSupport).toBeUndefined() expect(cfg.basemaps).toHaveLength(8) expect(cfg.nameProperty).toEqual('displayShortName') expect(cfg.defaultLayerSources).toHaveLength(6) diff --git a/src/util/app.js b/src/util/app.js index 62a88b5806..d9d45b5f8c 100644 --- a/src/util/app.js +++ b/src/util/app.js @@ -28,7 +28,7 @@ export const appQueries = { systemInfo: { resource: 'system/info', params: { - fields: 'calendar,dateFormat', + fields: 'calendar,dateFormat,databaseInfo[spatialSupport]', }, }, } @@ -76,6 +76,7 @@ export const providerDataTransformation = async ({ calendar: systemInfo.calendar, dateFormat: systemInfo.dateFormat, }, + spatialSupport: systemInfo.databaseInfo?.spatialSupport, basemaps: await getBasemapList({ externalMapLayers: externalMapLayers.externalMapLayers, systemSettings, From f4f9c97b3cf99e6591d1b4f28bcd5d55fe7ff4e6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:45:54 +0200 Subject: [PATCH 27/47] feat: expose spatialSupport from system info in dashboard plugin Co-Authored-By: Claude Sonnet 5 --- src/components/plugin/Plugin.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/plugin/Plugin.jsx b/src/components/plugin/Plugin.jsx index 9d9de5780c..64fd9313be 100644 --- a/src/components/plugin/Plugin.jsx +++ b/src/components/plugin/Plugin.jsx @@ -23,9 +23,19 @@ const query = { fields: `${CURRENT_USER_FIELDS},settings[keyAnalysisDisplayProperty]`, }, }, + systemInfo: { + resource: 'system/info', + params: { + fields: 'databaseInfo[spatialSupport]', + }, + }, } -const providerDataTransformation = ({ systemSettings, currentUser }) => { +const providerDataTransformation = ({ + systemSettings, + currentUser, + systemInfo, +}) => { return { systemSettings: { ...DEFAULT_SYSTEM_SETTINGS, @@ -47,6 +57,7 @@ const providerDataTransformation = ({ systemSettings, currentUser }) => { currentUser.settings.keyAnalysisDisplayProperty === 'name' ? 'displayName' : 'displayShortName', + spatialSupport: systemInfo.databaseInfo?.spatialSupport, } } From 3c37ec1729b47fbd20b3748a011f1c4ce6163482 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:47:10 +0200 Subject: [PATCH 28/47] feat: thread spatialSupport into event layer loader call sites Co-Authored-By: Claude Sonnet 5 --- src/components/plugin/LayerLoader.jsx | 3 +++ src/hooks/useLayersLoader.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index bd20f0ddcb..e2fcdc75a7 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -31,6 +31,7 @@ const LayerLoader = ({ config, onLoad }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { keyAnalysisDisplayProperty, @@ -59,6 +60,7 @@ const LayerLoader = ({ config, onLoad }) => { analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader + spatialSupport, // Event loader }).then((result) => { onLoad(result) }) @@ -74,6 +76,7 @@ const LayerLoader = ({ config, onLoad }) => { keyAnalysisDisplayProperty, keyAnalysisDigitGroupSeparator, serverVersion, + spatialSupport, ]) return null diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..549c345d56 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -34,6 +34,7 @@ export const useLayersLoader = () => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, currentUser, + spatialSupport, } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) @@ -65,6 +66,7 @@ export const useLayersLoader = () => { periodTypeData, // Thematic and Event loader serverVersion, // Tracked entity loader loadExtended: !!dataTable, // Event loader + spatialSupport, // Event loader }) if (result.alerts) { showAlerts(result.alerts) @@ -128,5 +130,6 @@ export const useLayersLoader = () => { baseUrl, dataTable, serverVersion, + spatialSupport, ]) } From 93de9c52e927fde526646aeb9e4d53effdf6fae4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:50:36 +0200 Subject: [PATCH 29/47] feat: gate server-side event clustering on backend spatial support Co-Authored-By: Claude Sonnet 5 --- src/loaders/__tests__/eventLoader.spec.js | 76 ++++++++++++++++++++++- src/loaders/eventLoader.js | 25 +++++--- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..cb5337ac61 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,12 +4,16 @@ import { USER_ORG_UNIT_GRANDCHILDREN, } from '@dhis2/analytics' import { WARNING_OU_BOUNDARIES_FETCH_FAILED } from '../../constants/alerts.js' +import { EVENT_SERVER_CLUSTER_COUNT } from '../../constants/layers.js' import { getUserOrgUnitIdsByKeyword } from '../../util/orgUnits.js' import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { excludeEventsOutsideOrgUnits } from '../eventLoader.js' +import { + excludeEventsOutsideOrgUnits, + shouldUseServerCluster, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +733,73 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +describe('shouldUseServerCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + test('returns true when over threshold and the backend supports spatial clustering', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(true) + }) + + test('returns false when over threshold but the backend has no spatial support', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: false, + }) + ).toBe(false) + }) + + test('returns false when over threshold but spatialSupport is undefined (fails closed)', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: undefined, + }) + ).toBe(false) + }) + + test('returns false when under threshold, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: EVENT_SERVER_CLUSTER_COUNT, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countFeaturesWithoutCoordinates is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: true, + countEventsOutsideOrgUnits: false, + spatialSupport: true, + }) + ).toBe(false) + }) + + test('returns false when countEventsOutsideOrgUnits is set, regardless of spatialSupport', () => { + expect( + shouldUseServerCluster({ + count: overThreshold, + countFeaturesWithoutCoordinates: false, + countEventsOutsideOrgUnits: true, + spatialSupport: true, + }) + ).toBe(false) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index e5d517a341..c980a0b037 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -58,12 +58,14 @@ const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { return [id] } -// Server clustering if more than 2000 events -const shouldUseServerCluster = ( +// Server clustering if more than 2000 events, and the backend supports it +export const shouldUseServerCluster = ({ count, countFeaturesWithoutCoordinates, - countEventsOutsideOrgUnits -) => + countEventsOutsideOrgUnits, + spatialSupport, +}) => + !!spatialSupport && !countFeaturesWithoutCoordinates && !countEventsOutsideOrgUnits && count > EVENT_SERVER_CLUSTER_COUNT @@ -97,6 +99,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { const config = { ...layerConfig, @@ -117,6 +120,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) } catch (e) { if ( @@ -149,6 +153,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + spatialSupport, }) => { // Config normalization // ----- @@ -280,11 +285,13 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster( - response.count, - config.countFeaturesWithoutCoordinates, - config.countEventsOutsideOrgUnits - ) + config.serverCluster = shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) serverCount = response.count } From 12aea132d8548f764b708fbdeed57b71ff1f63a5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:53:13 +0200 Subject: [PATCH 30/47] feat: add Redux action for forcing client-side event clustering Co-Authored-By: Claude Sonnet 5 --- src/actions/layers.js | 7 +++++++ src/constants/actionTypes.js | 1 + src/reducers/__tests__/map.spec.js | 18 ++++++++++++++++++ src/reducers/map.js | 11 +++++++++++ 4 files changed, 37 insertions(+) diff --git a/src/actions/layers.js b/src/actions/layers.js index 1af92b1d58..ad555375cb 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -66,3 +66,10 @@ export const setLayerLoading = (id) => ({ type: types.LAYER_LOADING_SET, id, }) + +// Force client-side clustering for a server-clustered event layer +// (session-only; intentionally excluded from validLayerProperties in favorites.js) +export const setForceClientCluster = (id) => ({ + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 96ed39d896..cc05ef1559 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -35,6 +35,7 @@ export const LAYER_TOGGLE_EXPAND = 'LAYER_TOGGLE_EXPAND' export const LAYER_TOGGLE_VISIBILITY = 'LAYER_TOGGLE_VISIBILITY' export const LAYER_UPDATE = 'LAYER_UPDATE' export const LAYER_DRILL = 'LAYER_DRILL' +export const LAYER_FORCE_CLIENT_CLUSTER_SET = 'LAYER_FORCE_CLIENT_CLUSTER_SET' /* DATA TABLE */ export const DATA_TABLE_CLOSE = 'DATA_TABLE_CLOSE' diff --git a/src/reducers/__tests__/map.spec.js b/src/reducers/__tests__/map.spec.js index 8164b8551f..24087e96df 100644 --- a/src/reducers/__tests__/map.spec.js +++ b/src/reducers/__tests__/map.spec.js @@ -456,6 +456,24 @@ describe('map reducer - per-layer delegation', () => { }) }) + describe('LAYER_FORCE_CLIENT_CLUSTER_SET', () => { + it('sets forceClientCluster on the matching layer only', () => { + const other = { id: 'layer2' } + const state = { + ...defaultState, + mapViews: [{ id: 'layer1' }, other], + } + + const result = map(state, { + type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, + id: 'layer1', + }) + + expect(result.mapViews[0].forceClientCluster).toBe(true) + expect(result.mapViews[1]).toBe(other) + }) + }) + describe('LAYER_TOGGLE_EXPAND', () => { it('toggles isExpanded on the matching layer only', () => { const other = { id: 'layer2', isExpanded: true } diff --git a/src/reducers/map.js b/src/reducers/map.js index b1c69de918..a4167a9314 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -126,6 +126,16 @@ const layer = (state, action) => { isVisible: !state.isVisible, } + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: + if (state.id !== action.id) { + return state + } + + return { + ...state, + forceClientCluster: true, + } + case types.LAYER_TOGGLE_EXPAND: if (state.id !== action.id) { return state @@ -322,6 +332,7 @@ const map = (state = defaultState, action) => { case types.LAYER_CHANGE_OPACITY: case types.LAYER_TOGGLE_VISIBILITY: case types.LAYER_TOGGLE_EXPAND: + case types.LAYER_FORCE_CLIENT_CLUSTER_SET: case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: case types.DATA_FILTERS_CLEAR_ALL: From a7ecc9805e0ab700d7eb28a5fd8c18134b6d4ba9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:53:50 +0200 Subject: [PATCH 31/47] test: confirm forceClientCluster is never saved to favorites Co-Authored-By: Claude Sonnet 5 --- src/util/__tests__/favorites.spec.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 92d9d97642..e1fdcb9f17 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -501,6 +501,26 @@ describe('cleanMapConfig', () => { ) }) + test('excludes forceClientCluster (session-only, not a valid layer property)', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Event layer', + opacity: 1, + serverCluster: true, + forceClientCluster: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'forceClientCluster' + ) + }) + test('writes hidden: true for a layer with isVisible: false', () => { const cleanedConfig = cleanMapConfig({ config: { From 9ec6e84785b5e52d7b9b5bfa079ca44a8c98ea81 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 14:59:42 +0200 Subject: [PATCH 32/47] feat: reload server-clustered event layers when forceClientCluster is set Co-Authored-By: Claude Sonnet 5 --- src/hooks/__tests__/useLayersLoader.spec.js | 175 ++++++++++++++++++++ src/hooks/useLayersLoader.js | 2 +- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/hooks/__tests__/useLayersLoader.spec.js diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js new file mode 100644 index 0000000000..7b70f8c23f --- /dev/null +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -0,0 +1,175 @@ +import { renderHook } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { EVENT_LAYER } from '../../constants/layers.js' +import eventLoader from '../../loaders/eventLoader.js' +import { useLayersLoader } from '../useLayersLoader.js' + +let mockCachedData + +// useLayersLoader.js pulls in earthEngineLoader.js -> util/earthEngine.js -> +// MapApi.js -> @dhis2/maps-gl, which needs browser APIs jsdom doesn't +// provide. Same workaround as earthEngineLoader.spec.js/trackedEntityLoader.spec.js. +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => ({}), + useConfig: () => ({ + baseUrl: 'https://example.org', + serverVersion: '2.42', + }), +})) + +jest.mock('@dhis2/app-service-alerts', () => ({ + useAlert: () => ({ show: jest.fn() }), +})) + +jest.mock('@dhis2/analytics', () => ({ + Analytics: { getAnalytics: jest.fn(() => ({})) }, + useDataOutputPeriodTypes: () => undefined, +})) + +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => mockCachedData, +})) + +// Never resolves - the reload-trigger tests only care about the synchronous +// setLayerLoading dispatch, not the loader's eventual result. +jest.mock('../../loaders/eventLoader.js', () => ({ + __esModule: true, + default: jest.fn(() => new Promise(() => {})), +})) + +const mockStore = configureMockStore() + +const renderWithStore = (state) => { + const store = mockStore(state) + const wrapper = ({ children }) => ( + {children} + ) + renderHook(() => useLayersLoader(), { wrapper }) + return { store } +} + +const baseLayer = { + id: 'a', + layer: EVENT_LAYER, + isLoaded: true, + isLoading: false, +} + +beforeEach(() => { + eventLoader.mockClear() + mockCachedData = { + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + currentUser: { + id: 'user1', + keyAnalysisDisplayProperty: 'name', + userOrgUnitIdsByKeyword: {}, + }, + spatialSupport: true, + } +}) + +describe('useLayersLoader - data table reload trigger', () => { + test('does not reload a server-clustered layer when the table opens and forceClientCluster is not set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: true, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('reloads a server-clustered layer once forceClientCluster is set', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: true, + isExtended: false, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) + + test('does not reload again once isExtended is true, even with forceClientCluster set (no loop)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { + ...baseLayer, + serverCluster: false, + isExtended: true, + forceClientCluster: true, + }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([]) + }) + + test('still reloads a non-clustered layer needing extended data (existing behavior preserved)', () => { + const { store } = renderWithStore({ + map: { + mapViews: [ + { ...baseLayer, serverCluster: false, isExtended: false }, + ], + }, + dataTable: 'a', + }) + + expect(store.getActions()).toEqual([ + { type: 'LAYER_LOADING_SET', id: 'a' }, + ]) + }) +}) + +describe('useLayersLoader - spatialSupport plumbing', () => { + test('passes spatialSupport from useCachedData into the event loader call', () => { + mockCachedData.spatialSupport = true + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: true }) + ) + }) + + test('passes spatialSupport: false through unchanged', () => { + mockCachedData.spatialSupport = false + + renderWithStore({ + map: { + mapViews: [{ ...baseLayer, isLoaded: false }], + }, + dataTable: null, + }) + + expect(eventLoader).toHaveBeenCalledWith( + expect.objectContaining({ spatialSupport: false }) + ) + }) +}) diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 549c345d56..66b1083b56 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -89,7 +89,7 @@ export const useLayersLoader = () => { layer.layer === EVENT_LAYER && layer.id === dataTable && !layer.isExtended && - !layer.serverCluster + (!layer.serverCluster || layer.forceClientCluster) ) { return true } From 596b8864c5652abe6457d016f2e9e0f401e64103 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:02:40 +0200 Subject: [PATCH 33/47] feat: bypass server-cluster decision when forceClientCluster is set Co-Authored-By: Claude Sonnet 5 --- src/loaders/eventLoader.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index c980a0b037..7dde003bfc 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -285,13 +285,15 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster({ - count: response.count, - countFeaturesWithoutCoordinates: - config.countFeaturesWithoutCoordinates, - countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, - spatialSupport, - }) + config.serverCluster = config.forceClientCluster + ? false + : shouldUseServerCluster({ + count: response.count, + countFeaturesWithoutCoordinates: + config.countFeaturesWithoutCoordinates, + countEventsOutsideOrgUnits: config.countEventsOutsideOrgUnits, + spatialSupport, + }) serverCount = response.count } From 54bd08c7d8eca1392078312b72551652279c0916 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:03:58 +0200 Subject: [PATCH 34/47] fix: allow opening the data table on a server-clustered event layer Co-Authored-By: Claude Sonnet 5 --- .../layers/toolbar/LayerToolbarMoreMenu.jsx | 8 +++-- .../__tests__/LayerToolbarMoreMenu.spec.jsx | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx index 0e0ad2dd67..4092711c2c 100644 --- a/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx +++ b/src/components/layers/toolbar/LayerToolbarMoreMenu.jsx @@ -15,7 +15,7 @@ import { import PropTypes from 'prop-types' import React, { useState, useRef } from 'react' import { connect } from 'react-redux' -import { EARTH_ENGINE_LAYER } from '../../../constants/layers.js' +import { EARTH_ENGINE_LAYER, EVENT_LAYER } from '../../../constants/layers.js' import { IconButton } from '../../core/index.js' import styles from './styles/LayerToolbarMore.module.css' @@ -170,8 +170,12 @@ export default connect( { layer = DEFAULT_EMPTY_LAYER } ) => { const isEarthEngine = layer.layer === EARTH_ENGINE_LAYER + const isServerClusteredEvent = + layer.layer === EVENT_LAYER && layer.serverCluster const hasOrgUnitData = - layer.data && (!isEarthEngine || layer.aggregationType?.length > 0) + isServerClusteredEvent || + (layer.data && + (!isEarthEngine || layer.aggregationType?.length > 0)) const isLoading = isEarthEngine && hasOrgUnitData && !aggregations[layer.id] diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index 314b731eaa..bb53373138 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -150,6 +150,39 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('enables Show data table for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + + + + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Show data table')).toBeTruthy() + expect( + screen + .queryByText('Show data table') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { aggregations: {}, From cc70f7521dd4ed2442c438584aa72a3dd065cd3a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:09:27 +0200 Subject: [PATCH 35/47] fix: stop hard-erroring the data table for server-clustered event layers Co-Authored-By: Claude Sonnet 5 --- .../datatable/__tests__/useTableData.spec.jsx | 55 +++++++++++++++++++ src/components/datatable/useTableData.js | 14 ++--- src/util/__tests__/tableRows.spec.js | 10 +--- src/util/tableRows.js | 3 +- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 1888265add..ee9fd055cc 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -575,6 +575,61 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('is not "extending" a server-clustered event layer that has not been forced to client-cluster', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.isLoading).toBe(false) + expect(result.current.loadingReason).toBeNull() + }) + + test('shows "Loading additional events…" while forceClientCluster reload is in flight', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + serverCluster: true, + forceClientCluster: true, + isExtended: false, + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + expect(result.current.isLoading).toBe(true) + expect(result.current.loadingReason).toBe('Loading additional events…') + }) + test('gets headers and rows for tracked entity layer', () => { const store = { aggregations: {}, diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index f91988f8bd..131941cb7b 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -26,21 +26,13 @@ import { ERROR_NON_HOMOGENOUS_FEATURES, getHeadersForLayer, } from '../../util/tableHeaders.js' -import { - ERROR_SERVER_CLUSTER, - ERROR_NO_VALID_DATA, - buildTableData, -} from '../../util/tableRows.js' +import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js' import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' const ERROR_NO_HEADERS = 'NO_HEADERS' const getErrorCodeText = (code) => { switch (code) { - case ERROR_SERVER_CLUSTER: - return i18n.t( - 'Data table is not supported when events are grouped on the server.' - ) case ERROR_NO_VALID_DATA: return i18n.t( 'No valid data was found for the current layer configuration.' @@ -308,7 +300,9 @@ export const useTableData = ({ aggregationType?.length && (!aggregations || aggregations === EMPTY_AGGREGATIONS) const isExtendingEvents = - layerType === EVENT_LAYER && !layer.isExtended && !serverCluster + layerType === EVENT_LAYER && + !layer.isExtended && + !!(!serverCluster || layer.forceClientCluster) const isLoading = isLoadingAggregations || isExtendingEvents let loadingReason = null if (isLoadingAggregations) { diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index fffcf07bf9..3945e96ab3 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,9 +1,5 @@ import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' -import { - buildTableData, - ERROR_NO_VALID_DATA, - ERROR_SERVER_CLUSTER, -} from '../tableRows.js' +import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' // Thematic-layer-shaped feature: id is stamped on both the top level (which // is what aggregations are keyed by) and properties (see the deferred @@ -15,9 +11,9 @@ const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ }) describe('buildTableData - error paths', () => { - test('server-clustered layers return an error code instead of data', () => { + test('server-clustered layers return empty data instead of an error', () => { expect(buildTableData(THEMATIC_LAYER, { serverCluster: true })).toEqual( - { errorCode: ERROR_SERVER_CLUSTER } + { data: [] } ) }) diff --git a/src/util/tableRows.js b/src/util/tableRows.js index bef21fa009..2c47cd34a5 100644 --- a/src/util/tableRows.js +++ b/src/util/tableRows.js @@ -2,7 +2,6 @@ import { GEOJSON_URL_LAYER } from '../constants/layers.js' import { isFeatureInBounds } from './geojson.js' import { formatRangeWithSeparator } from './numbers.js' -export const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' export const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' export const buildTableData = ( @@ -26,7 +25,7 @@ export const buildTableData = ( } ) => { if (serverCluster) { - return { errorCode: ERROR_SERVER_CLUSTER } + return { data: [] } } const allData = dataWithoutCoords?.length From 074dab78478738ec04f01d0f8e8f98e47cb8c5d4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:10:45 +0200 Subject: [PATCH 36/47] feat: add in-table action to show event details for a clustered layer Co-Authored-By: Claude Sonnet 5 --- .../datatable/TableVirtuosoComponents.jsx | 19 +++++- .../TableVirtuosoComponents.spec.jsx | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index fd58eae97b..d78ae2b03f 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -55,12 +55,25 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } -const EmptyPlaceholder = ({ context }) => ( +export const EmptyPlaceholder = ({ context }) => (
- {context.totalCount > 0 ? ( + {context.showServerClusterAction ? ( + <> + {i18n.t( + "Event details aren't available while this layer is clustered on the server" + )} + + + ) : context.totalCount > 0 ? ( <> {i18n.t('No features match your filters')} {context.hasActiveFilters && ( @@ -85,8 +98,10 @@ const EmptyPlaceholder = ({ context }) => ( EmptyPlaceholder.propTypes = { context: PropTypes.shape({ hasActiveFilters: PropTypes.bool, + showServerClusterAction: PropTypes.bool, totalCount: PropTypes.number, onClearFilters: PropTypes.func, + onForceClientCluster: PropTypes.func, }), } diff --git a/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx new file mode 100644 index 0000000000..0134dc4510 --- /dev/null +++ b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx @@ -0,0 +1,61 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { EmptyPlaceholder } from '../TableVirtuosoComponents.jsx' + +const renderPlaceholder = (context) => + render( + + +
+ ) + +describe('EmptyPlaceholder', () => { + test('shows the server-cluster action when showServerClusterAction is true', () => { + const onForceClientCluster = jest.fn() + renderPlaceholder({ + showServerClusterAction: true, + onForceClientCluster, + }) + + expect( + screen.getByText( + "Event details aren't available while this layer is clustered on the server" + ) + ).toBeTruthy() + + fireEvent.click(screen.getByText('Show event details')) + expect(onForceClientCluster).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('No features match your filters')).toBeNull() + expect(screen.queryByText('No results found')).toBeNull() + }) + + test('shows the clear-filters action when filters produced zero rows', () => { + const onClearFilters = jest.fn() + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 10, + hasActiveFilters: true, + onClearFilters, + }) + + expect(screen.getByText('No features match your filters')).toBeTruthy() + + fireEvent.click(screen.getByText('Clear filters')) + expect(onClearFilters).toHaveBeenCalledTimes(1) + + expect(screen.queryByText('Show event details')).toBeNull() + }) + + test('shows plain "No results found" when there is no data at all', () => { + renderPlaceholder({ + showServerClusterAction: false, + totalCount: 0, + hasActiveFilters: false, + }) + + expect(screen.getByText('No results found')).toBeTruthy() + expect(screen.queryByText('Show event details')).toBeNull() + expect(screen.queryByText('No features match your filters')).toBeNull() + }) +}) From 5c422c2b544223d9adeb0ca64c363932ff9b6ba0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:12:15 +0200 Subject: [PATCH 37/47] feat: wire the show-event-details action into the data table Co-Authored-By: Claude Sonnet 5 --- src/components/datatable/DataTable.jsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index ad3f1ab295..11880e8b57 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -22,6 +22,7 @@ import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' +import { setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, @@ -289,6 +290,14 @@ const Table = ({ showOnlyFeaturesInView, }) + const showServerClusterAction = + layer.serverCluster && !layer.forceClientCluster + + const onForceClientCluster = useCallback( + () => dispatch(setForceClientCluster(layer.id)), + [dispatch, layer.id] + ) + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, @@ -300,6 +309,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, }), [ setFeatureHighlight, @@ -311,6 +322,8 @@ const Table = ({ totalCount, hasActiveFilters, onClearFilters, + showServerClusterAction, + onForceClientCluster, ] ) From ee8321076c752749546cb18ec9711cc154f3d598 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:41:02 +0200 Subject: [PATCH 38/47] fix: don't mark a server-clustered event layer's table data as ready isExtended was set unconditionally before the server-cluster decision, so a layer that became server-clustered while its data table was open got isExtended:true stamped even though no client dataset was ever loaded. That silently blocked the reload useLayersLoader triggers when forceClientCluster is set, so clicking "Show event details" appeared to do nothing instead of loading the table. Co-Authored-By: Claude Sonnet 5 --- src/loaders/__tests__/eventLoader.spec.js | 104 +++++++++++++++++++++- src/loaders/eventLoader.js | 22 +++-- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index cb5337ac61..0396cb5c9c 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -10,7 +10,7 @@ import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { +import eventLoader, { excludeEventsOutsideOrgUnits, shouldUseServerCluster, } from '../eventLoader.js' @@ -803,3 +803,105 @@ describe('shouldUseServerCluster', () => { ).toBe(false) }) }) + +// A minimal chainable stand-in for the real analytics request builder - +// every method just returns `this` so the request-building chain in +// util/event.js's getAnalyticsRequest completes without error. +class FakeAnalyticsRequest { + withProgram() { + return this + } + withStage() { + return this + } + withCoordinatesOnly() { + return this + } + withStartDate() { + return this + } + withEndDate() { + return this + } + addPeriodFilter() { + return this + } + withRelativePeriodDate() { + return this + } + addOrgUnitDimension() { + return this + } + addDimension() { + return this + } + withCoordinateField() { + return this + } + withEventStatus() { + return this + } + withPageSize() { + return this + } +} + +describe('eventLoader - isExtended vs serverCluster', () => { + const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 + + const baseConfig = () => ({ + program: { id: 'prog1' }, + programStage: { id: 'stage1', name: 'Stage 1' }, + columns: [], + filters: [], + rows: [], + eventClustering: true, + startDate: '2024-01-01', + endDate: '2024-01-31', + }) + + const makeArgs = (config) => ({ + config, + engine: { + query: jest.fn().mockResolvedValue({ + programStage: { programStageDataElements: [] }, + }), + }, + keyAnalysisDisplayProperty: 'name', + keyAnalysisDigitGroupSeparator: 'NONE', + analyticsEngine: { + request: FakeAnalyticsRequest, + events: { + getCount: jest + .fn() + .mockResolvedValue({ count: overThreshold, extent: null }), + getQuery: jest.fn().mockResolvedValue({ + headers: [], + metaData: { items: {}, pager: { total: 0 } }, + rows: [], + }), + }, + }, + periodTypeData: undefined, + loadExtended: true, + spatialSupport: true, + }) + + test('does not claim the table has extended data when the layer ends up server-clustered', async () => { + const result = await eventLoader(makeArgs(baseConfig())) + + expect(result.serverCluster).toBe(true) + expect(result.isExtended).toBe(false) + expect(result.data).toBeUndefined() + }) + + test('forceClientCluster loads the extended dataset instead of staying server-clustered', async () => { + const result = await eventLoader( + makeArgs({ ...baseConfig(), forceClientCluster: true }) + ) + + expect(result.serverCluster).toBe(false) + expect(result.isExtended).toBe(true) + expect(result.data).toEqual([]) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 7dde003bfc..2192a091da 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,13 +244,17 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - config.isExtended = loadExtended - - const analyticsRequest = await getAnalyticsRequest(config, { - analyticsEngine, - nameProperty: displayNameProp, - engine, - }) + // Request setup only - config.isExtended (the UI-facing "table has its + // extended dataset" flag) is set further down, once we know whether + // server clustering will actually skip loading that dataset. + const analyticsRequest = await getAnalyticsRequest( + { ...config, isExtended: loadExtended }, + { + analyticsEngine, + nameProperty: displayNameProp, + engine, + } + ) const alerts = [] // Legend skeleton @@ -297,6 +301,10 @@ const loadEventLayer = async ({ serverCount = response.count } + // The extended (data table) dataset is only actually loaded below when + // server clustering isn't in effect - don't claim it's ready otherwise. + config.isExtended = loadExtended && !config.serverCluster + // Load event data // ----- From 7e6e1dad7dcadd830c0ef5c4df1ea6954e0cfdf9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 15:44:37 +0200 Subject: [PATCH 39/47] fix: show the loaded (capped) event count in the legend, not the raw total, once rendering client-side Co-Authored-By: Claude Sonnet 5 --- src/loaders/__tests__/eventLoader.spec.js | 5 +++++ src/loaders/eventLoader.js | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 0396cb5c9c..e99690f9ab 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -893,6 +893,8 @@ describe('eventLoader - isExtended vs serverCluster', () => { expect(result.serverCluster).toBe(true) expect(result.isExtended).toBe(false) expect(result.data).toBeUndefined() + // Server clustering isn't capped - the legend shows the true total. + expect(result.legend.items[0].count).toBe(overThreshold) }) test('forceClientCluster loads the extended dataset instead of staying server-clustered', async () => { @@ -903,5 +905,8 @@ describe('eventLoader - isExtended vs serverCluster', () => { expect(result.serverCluster).toBe(false) expect(result.isExtended).toBe(true) expect(result.data).toEqual([]) + // Once rendering client-side, the legend must reflect what was + // actually loaded (capped), not the raw analytics total. + expect(result.legend.items[0].count).toBe(0) }) }) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 2192a091da..6fb1ab7b04 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -448,9 +448,16 @@ const loadEventLayer = async ({ color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - count: - serverCount || - (Array.isArray(config?.data) ? config.data.length : 0), + // Server clustering isn't capped, so the true total + // (serverCount) is accurate. Once rendering client-side + // (whether never server-clustered, or forced via + // forceClientCluster), only the loaded/capped data reflects + // what's actually shown. + count: config.serverCluster + ? serverCount + : Array.isArray(config?.data) + ? config.data.length + : 0, }, ] } From 3069a085122788345aeba0104dc7ee285de78276 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 16:03:47 +0200 Subject: [PATCH 40/47] chore: PR clean-up --- src/actions/layers.js | 3 +-- src/hooks/__tests__/useLayersLoader.spec.js | 3 --- src/loaders/__tests__/eventLoader.spec.js | 4 +--- src/loaders/eventLoader.js | 11 ++--------- src/util/__tests__/tableRows.spec.js | 3 --- 5 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/actions/layers.js b/src/actions/layers.js index ad555375cb..4583af12f7 100644 --- a/src/actions/layers.js +++ b/src/actions/layers.js @@ -67,8 +67,7 @@ export const setLayerLoading = (id) => ({ id, }) -// Force client-side clustering for a server-clustered event layer -// (session-only; intentionally excluded from validLayerProperties in favorites.js) +// Force client-side clustering for a server-clustered event layer (session-only) export const setForceClientCluster = (id) => ({ type: types.LAYER_FORCE_CLIENT_CLUSTER_SET, id, diff --git a/src/hooks/__tests__/useLayersLoader.spec.js b/src/hooks/__tests__/useLayersLoader.spec.js index 7b70f8c23f..19fec7e93b 100644 --- a/src/hooks/__tests__/useLayersLoader.spec.js +++ b/src/hooks/__tests__/useLayersLoader.spec.js @@ -8,9 +8,6 @@ import { useLayersLoader } from '../useLayersLoader.js' let mockCachedData -// useLayersLoader.js pulls in earthEngineLoader.js -> util/earthEngine.js -> -// MapApi.js -> @dhis2/maps-gl, which needs browser APIs jsdom doesn't -// provide. Same workaround as earthEngineLoader.spec.js/trackedEntityLoader.spec.js. jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index e99690f9ab..7a58552fea 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -804,9 +804,7 @@ describe('shouldUseServerCluster', () => { }) }) -// A minimal chainable stand-in for the real analytics request builder - -// every method just returns `this` so the request-building chain in -// util/event.js's getAnalyticsRequest completes without error. +// A minimal chainable stand-in for the real analytics request builder class FakeAnalyticsRequest { withProgram() { return this diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 6fb1ab7b04..fbcc194efe 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,9 +244,7 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - // Request setup only - config.isExtended (the UI-facing "table has its - // extended dataset" flag) is set further down, once we know whether - // server clustering will actually skip loading that dataset. + // Request setup only - config.isExtended is set further dow const analyticsRequest = await getAnalyticsRequest( { ...config, isExtended: loadExtended }, { @@ -302,7 +300,7 @@ const loadEventLayer = async ({ } // The extended (data table) dataset is only actually loaded below when - // server clustering isn't in effect - don't claim it's ready otherwise. + // server clustering isn't in effect - don't claim it's ready otherwise config.isExtended = loadExtended && !config.serverCluster // Load event data @@ -448,11 +446,6 @@ const loadEventLayer = async ({ color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - // Server clustering isn't capped, so the true total - // (serverCount) is accurate. Once rendering client-side - // (whether never server-clustered, or forced via - // forceClientCluster), only the loaded/capped data reflects - // what's actually shown. count: config.serverCluster ? serverCount : Array.isArray(config?.data) diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index 3945e96ab3..5fdbd97232 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,9 +1,6 @@ import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' -// Thematic-layer-shaped feature: id is stamped on both the top level (which -// is what aggregations are keyed by) and properties (see the deferred -// id-placement inconsistency called out for this codebase's loaders). const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ id, geometry: { type: 'Point', coordinates }, From 3c53be44aa788fd8a381764e73e56d0d32e5bf93 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 16:09:26 +0200 Subject: [PATCH 41/47] refactor: extract nested ternaries flagged by SonarQube Co-Authored-By: Claude Sonnet 5 --- .../datatable/TableVirtuosoComponents.jsx | 68 +++++++++++-------- src/loaders/eventLoader.js | 13 ++-- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx index d78ae2b03f..0acdaa1e88 100644 --- a/src/components/datatable/TableVirtuosoComponents.jsx +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -55,40 +55,50 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } +const getEmptyPlaceholderContent = (context) => { + if (context.showServerClusterAction) { + return ( + <> + {i18n.t( + "Event details aren't available while this layer is clustered on the server" + )} + + + ) + } + + if (context.totalCount > 0) { + return ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + + )} + + ) + } + + return i18n.t('No results found') +} + export const EmptyPlaceholder = ({ context }) => (
- {context.showServerClusterAction ? ( - <> - {i18n.t( - "Event details aren't available while this layer is clustered on the server" - )} - - - ) : context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - - )} - - ) : ( - i18n.t('No results found') - )} + {getEmptyPlaceholderContent(context)}
diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index fbcc194efe..9cbeec0b40 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -440,17 +440,20 @@ const loadEventLayer = async ({ const color = cssColor(eventPointColor) || EVENT_COLOR const strokeColor = getContrastColor(color) + let count = 0 + if (config.serverCluster) { + count = serverCount + } else if (Array.isArray(config?.data)) { + count = config.data.length + } + config.legend.items = [ { name: i18n.t('Event'), color, strokeColor, radius: eventPointRadius || EVENT_RADIUS, - count: config.serverCluster - ? serverCount - : Array.isArray(config?.data) - ? config.data.length - : 0, + count, }, ] } From 02e91878d8860d5474dcb3ff88d562de6a6bdc83 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 23 Jul 2026 17:43:40 +0200 Subject: [PATCH 42/47] fix: PR clean-up --- src/components/datatable/BottomPanel.jsx | 11 ++- src/components/datatable/DataTable.jsx | 7 ++ .../datatable/__tests__/BottomPanel.spec.jsx | 81 +++++++++++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 2 +- .../__tests__/LayerToolbarMoreMenu.spec.jsx | 34 ++++++++ src/constants/dataTable.js | 1 + src/loaders/eventLoader.js | 2 +- src/util/__tests__/tableRows.spec.js | 50 +++++++++++- src/util/tableHeaders.js | 3 +- src/util/tableRows.js | 8 +- 10 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 src/components/datatable/__tests__/BottomPanel.spec.jsx diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 4399c78c4c..a1508c90cd 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -56,6 +56,7 @@ const BottomPanel = () => { const { height } = useWindowDimensions() const panelRef = useRef(null) const isDraggingRef = useRef(false) + const preDragCollapsedRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) @@ -97,7 +98,8 @@ const BottomPanel = () => { const onResizeStart = useCallback(() => { isDraggingRef.current = true - }, []) + preDragCollapsedRef.current = isCollapsed + }, [isCollapsed]) const onResize = useCallback( (h) => { @@ -125,7 +127,12 @@ const BottomPanel = () => { const onResizeCancel = useCallback(() => { isDraggingRef.current = false - }, []) + setIsCollapsed(preDragCollapsedRef.current) + document.documentElement.style.setProperty( + '--data-table-height', + `${displayHeight}px` + ) + }, [displayHeight]) const onCountChange = useCallback((total, filtered) => { setTotalCount(total) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 11880e8b57..4c71beca75 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -32,6 +32,7 @@ import { SORT_ASCENDING, RENDERER_COLOR, RENDERER_ICON, + RENDERER_DATE, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { @@ -43,6 +44,7 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' +import { formatDatetime } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, @@ -603,6 +605,7 @@ const Table = ({ const renderer = rendererByDataKey.get(dataKey) const isColorCell = renderer === RENDERER_COLOR const isIconCell = renderer === RENDERER_ICON + const isDateCell = renderer === RENDERER_DATE return ( )} + {isDateCell && + value && + formatDatetime(value)} {!isColorCell && !isIconCell && + !isDateCell && formatWithSeparator( value, keyAnalysisDigitGroupSeparator diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx new file mode 100644 index 0000000000..314397bfae --- /dev/null +++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx @@ -0,0 +1,81 @@ +import { render, fireEvent } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx' +import BottomPanel from '../BottomPanel.jsx' + +jest.mock('../DataTable.jsx', () => { + const DataTableMock = () =>
+ DataTableMock.displayName = 'DataTableMock' + return DataTableMock +}) + +const mockStore = configureMockStore() + +// jsdom doesn't implement pointer capture or ResizeObserver +beforeAll(() => { + Element.prototype.setPointerCapture = jest.fn() + Element.prototype.releasePointerCapture = jest.fn() + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +const DATA_TABLE_HEIGHT = 300 + +const renderBottomPanel = () => { + const store = mockStore({ + ui: { + dataTableHeight: DATA_TABLE_HEIGHT, + showOnlyFeaturesInView: false, + selectionFilter: [], + highlightColor: null, + }, + dataTable: 'layer1', + map: { mapViews: [{ id: 'layer1', name: 'Layer 1' }] }, + }) + const { container } = render( + + + + + + ) + return { handle: container.querySelector('.resizeHandle') } +} + +const getDisplayHeight = () => + document.documentElement.style.getPropertyValue('--data-table-height') + +describe('BottomPanel resize cancel', () => { + test('cancelling a drag that never collapsed the panel reverts the transient height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 600 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) + + test('cancelling a drag that collapsed the panel restores the pre-drag expanded height', () => { + const { handle } = renderBottomPanel() + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }) + // Drag far enough down to cross the collapse threshold (MIN_HEIGHT) + fireEvent.pointerMove(handle, { + pointerId: 1, + clientY: window.innerHeight, + }) + expect(getDisplayHeight()).not.toBe(`${DATA_TABLE_HEIGHT}px`) + + fireEvent.pointerCancel(handle, { pointerId: 1, clientY: 0 }) + expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index ee9fd055cc..bcfb69f92e 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -554,7 +554,7 @@ describe('useTableData headers', () => { name: 'Event time', dataKey: 'eventdate', type: 'date', - renderer: 'formatTime...', + renderer: 'renderdate', }, { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, diff --git a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx index bb53373138..f524fa7bce 100644 --- a/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx +++ b/src/components/layers/toolbar/__tests__/LayerToolbarMoreMenu.spec.jsx @@ -183,6 +183,40 @@ describe('LayerToolbarMoreMenu', () => { }) }) + test('also enables Download data for a server-clustered event layer with no data yet', async () => { + const store = { + aggregations: {}, + } + + const layer = { + id: 'rainbowdash', + layer: 'event', + serverCluster: true, + } + + render( + + + + ) + + fireEvent.click(screen.getByLabelText('Toggle layer menu')) + + await waitFor(() => { + expect(screen.queryByText('Download data')).toBeTruthy() + expect( + screen + .queryByText('Download data') + .closest('li') + .classList.contains('disabled') + ).toBe(false) + }) + }) + test('renders three MenuItems WITH divider if passed toggleDataTable, onEdit, and onRemove', async () => { const store = { aggregations: {}, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 94d4a9cb05..a89d40516f 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -7,6 +7,7 @@ export const SORT_DESCENDING = 'desc' export const RENDERER_COLOR = 'rendercolor' export const RENDERER_ICON = 'rendericon' +export const RENDERER_DATE = 'renderdate' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 9cbeec0b40..275ded1dbf 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -244,7 +244,7 @@ const loadEventLayer = async ({ const dataFilters = getFiltersFromColumns(columns) - // Request setup only - config.isExtended is set further dow + // Request setup only - config.isExtended is set further down const analyticsRequest = await getAnalyticsRequest( { ...config, isExtended: loadExtended }, { diff --git a/src/util/__tests__/tableRows.spec.js b/src/util/__tests__/tableRows.spec.js index 5fdbd97232..c66d39a5dd 100644 --- a/src/util/__tests__/tableRows.spec.js +++ b/src/util/__tests__/tableRows.spec.js @@ -1,4 +1,8 @@ -import { GEOJSON_URL_LAYER, THEMATIC_LAYER } from '../../constants/layers.js' +import { + GEOJSON_URL_LAYER, + THEMATIC_LAYER, + TRACKED_ENTITY_LAYER, +} from '../../constants/layers.js' import { buildTableData, ERROR_NO_VALID_DATA } from '../tableRows.js' const feature = (id, extraProperties = {}, coordinates = [10, 10]) => ({ @@ -32,10 +36,16 @@ describe('buildTableData - geoJsonUrl layer', () => { ] const result = buildTableData(GEOJSON_URL_LAYER, { data }) expect(result.data).toEqual([ - { id: 'a', name: 'A', hasAdditionalGeometry: true }, - { id: 'b', name: 'B' }, + { id: 'a', name: 'A', hasAdditionalGeometry: true, index: 0 }, + { id: 'b', name: 'B', index: 1 }, ]) }) + + test('stamps a row-order index so clearing a sort restores natural order', () => { + const data = [feature('a'), feature('b'), feature('c')] + const result = buildTableData(GEOJSON_URL_LAYER, { data }) + expect(result.data.map((r) => r.index)).toEqual([0, 1, 2]) + }) }) describe('buildTableData - showOnlyFeaturesInView', () => { @@ -83,6 +93,40 @@ describe('buildTableData - generic layer', () => { }) }) +describe('buildTableData - tracked entity layer', () => { + test('merges data and dataWithoutCoords, drops features with hasAdditionalGeometry, merges aggregations and stamps a row-order index', () => { + const data = [feature('a', { w75KJ2mc4zz: 'Gabrielle' })] + const dataWithoutCoords = [ + feature('b', { + w75KJ2mc4zz: 'Hidden', + hasAdditionalGeometry: true, + }), + feature('c', { w75KJ2mc4zz: 'Charlie' }), + ] + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data, + dataWithoutCoords, + aggregations: { a: { count: 5 } }, + }) + expect(result.data).toEqual([ + { id: 'a', w75KJ2mc4zz: 'Gabrielle', count: 5, index: 0 }, + { id: 'c', w75KJ2mc4zz: 'Charlie', index: 1 }, + ]) + }) + + test('filters out-of-view features when showOnlyFeaturesInView is on', () => { + const inBounds = feature('in', {}, [10, 10]) + const outOfBounds = feature('out', {}, [100, 100]) + const result = buildTableData(TRACKED_ENTITY_LAYER, { + data: [inBounds, outOfBounds], + showOnlyFeaturesInView: true, + mapBounds: [0, 0, 20, 20], + aggregations: {}, + }) + expect(result.data.map((r) => r.id)).toEqual(['in']) + }) +}) + describe('buildTableData - styled event layer', () => { test('derives legend name and a formatted range from the matching legend item', () => { const data = [feature('a', { colorGroup: 0 })] diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 088c87fa0c..3c42d4ac88 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -2,6 +2,7 @@ import i18n from '@dhis2/d2-i18n' import { RENDERER_COLOR, RENDERER_ICON, + RENDERER_DATE, TYPE_NUMBER, TYPE_STRING, TYPE_DATE, @@ -63,7 +64,7 @@ const defaultFieldsMap = () => ({ name: i18n.t('Event time'), dataKey: EVENTDATE, type: TYPE_DATE, - renderer: 'formatTime...', + renderer: RENDERER_DATE, }, [COLOR]: { name: i18n.t('Color'), diff --git a/src/util/tableRows.js b/src/util/tableRows.js index 2c47cd34a5..60719159d3 100644 --- a/src/util/tableRows.js +++ b/src/util/tableRows.js @@ -41,7 +41,13 @@ export const buildTableData = ( : allData if (layerType === GEOJSON_URL_LAYER) { - return { data: inViewData.map((d) => ({ ...d.properties })) } + return { + data: inViewData.map((d, index) => ({ + ...d.properties, + // Row-order tie-breaker for compareRows when no sortField is set + index, + })), + } } const rows = inViewData From 193a12010bea87b9ee12bcf8f77caad4c510e3ce Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 24 Jul 2026 12:12:27 +0200 Subject: [PATCH 43/47] fix: PR clean-up --- cypress/integration/layers/eventlayer.cy.js | 2 +- src/components/core/Checkbox.jsx | 5 +- .../core/styles/Checkbox.module.css | 2 +- src/components/datatable/DataTable.jsx | 31 ++++- src/components/datatable/ErrorBoundary.jsx | 5 +- src/components/datatable/FilterInput.jsx | 45 ++++--- .../datatable/__tests__/FilterInput.spec.jsx | 19 ++- .../__tests__/useColumnWidths.spec.jsx | 115 ++++++++++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 9 +- .../datatable/styles/DataTable.module.css | 22 +++- .../datatable/styles/ErrorBoundary.module.css | 5 + .../datatable/styles/FilterInput.module.css | 25 ++++ src/components/datatable/useColumnWidths.js | 6 +- src/components/map/layers/EventPopup.jsx | 6 +- src/constants/dataTable.js | 4 + src/constants/valueTypes.js | 3 + src/util/__tests__/filter.spec.js | 96 ++++++++++++++- src/util/__tests__/filterInput.spec.js | 22 ---- src/util/__tests__/tableHeaders.spec.js | 69 +++++++++++ src/util/filter.js | 28 +++++ src/util/filterInput.js | 10 +- src/util/helpers.js | 2 +- src/util/tableHeaders.js | 72 ++++++++--- src/util/time.js | 2 +- 24 files changed, 511 insertions(+), 94 deletions(-) create mode 100644 src/components/datatable/__tests__/useColumnWidths.spec.jsx create mode 100644 src/components/datatable/styles/ErrorBoundary.module.css diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js index 9b77e74e3a..ab813a44f7 100644 --- a/cypress/integration/layers/eventlayer.cy.js +++ b/cypress/integration/layers/eventlayer.cy.js @@ -338,7 +338,7 @@ context('Event Layers', () => { 'Event location', '-13.188339, 8.405215', 'Organisation unit', - 'Event time', + 'Event date', 'Age in years', 'Mode of Discharge', ]) diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 5567af53e2..2f19b9e406 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -8,6 +8,7 @@ import styles from './styles/Checkbox.module.css' const Checkbox = ({ label, checked = false, + indeterminate = false, disabled, dense = true, tooltip, @@ -23,7 +24,8 @@ const Checkbox = ({ > onChange(checked)} @@ -43,6 +45,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, + indeterminate: PropTypes.bool, label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, diff --git a/src/components/core/styles/Checkbox.module.css b/src/components/core/styles/Checkbox.module.css index ead0686f2f..410baa373c 100644 --- a/src/components/core/styles/Checkbox.module.css +++ b/src/components/core/styles/Checkbox.module.css @@ -5,7 +5,7 @@ } /* This styles the tooltip span containing the svg */ -.checkbox span { +.checkbox > span { margin-left: var(--spacers-dp4); display: flex; flex-direction: column; diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 4c71beca75..a6d9691cc7 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -22,7 +22,7 @@ import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' -import { setForceClientCluster } from '../../actions/layers.js' +import { editLayer, setForceClientCluster } from '../../actions/layers.js' import { toggleFeatureSelection, selectFeatureRange, @@ -33,6 +33,7 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + TYPE_DATE, } from '../../constants/dataTable.js' import { isDarkColor } from '../../util/colors.js' import { @@ -44,7 +45,7 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { formatDatetime } from '../../util/helpers.js' +import { formatDate, formatDatetime } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, @@ -201,7 +202,7 @@ const Table = ({ ) const visibleHeaders = useMemo( - () => getVisibleHeaders(headers, columnConfig), + () => getVisibleHeaders(headers, columnConfig) ?? [], [headers, columnConfig] ) @@ -210,6 +211,11 @@ const Table = ({ [visibleHeaders] ) + const typeByDataKey = useMemo( + () => new Map(visibleHeaders.map((h) => [h.dataKey, h.type])), + [visibleHeaders] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, headers: visibleHeaders, @@ -533,7 +539,18 @@ const Table = ({ ) if (error) { - return

{error}

+ return ( +

+ {error} + +

+ ) } return ( @@ -606,6 +623,8 @@ const Table = ({ const isColorCell = renderer === RENDERER_COLOR const isIconCell = renderer === RENDERER_ICON const isDateCell = renderer === RENDERER_DATE + const isDateOnlyCell = + typeByDataKey.get(dataKey) === TYPE_DATE return ( -

{i18n.t('Something went wrong')}

+

+ {i18n.t('Something went wrong')} +

) } diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index e536d6d6cb..0741860ad6 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -12,6 +12,9 @@ import { RENDERER_COLOR, RENDERER_ICON, TYPE_NUMBER, + // TYPE_DATE, + // TYPE_DATETIME, + // TYPE_TIME, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { @@ -20,10 +23,11 @@ import { getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, - hasMatchingOptionLabel, measureMaxTextWidth, toHighlightedIndex, toOptionIndex, + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, } from '../../util/filterInput.js' import { getInvertibleValues, @@ -34,6 +38,7 @@ import { import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' +// import DateGroupFilterInput from './DateGroupFilterInput.jsx' import { FilterDropdownPopover, getDropdownPlacement, @@ -41,8 +46,6 @@ import { import FilterHelpTooltip from './FilterHelpTooltip.jsx' import styles from './styles/FilterInput.module.css' -const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized -const MAX_LIST_HEIGHT = 260 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -183,17 +186,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ }), [realOptions, trimmedSearch, normalizedSearch, type, resolveLabel] ) - const hasExactMatch = useMemo( - () => - hasMatchingOptionLabel( - filteredOptions, - resolveLabel, - normalizedSearch - ), - [filteredOptions, resolveLabel, normalizedSearch] - ) - const showCustomFilterRow = - allowCustomFilter && normalizedSearch !== '' && !hasExactMatch + const showCustomFilterRow = allowCustomFilter && normalizedSearch !== '' const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) const customFilterTag = @@ -221,15 +214,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ return } - const normalized = trimmed.toLowerCase() - const exactMatch = hasMatchingOptionLabel( - options, - resolveLabel, - normalized - ) - if (!exactMatch) { - applyCustomFilter(trimmed) - } + applyCustomFilter(trimmed) } const scrollHighlightedIntoView = (index) => { @@ -576,6 +561,20 @@ const FilterInput = React.memo(function FilterInput({ const filterValue = filters?.[dataKey] + /* const isDateType = + type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME */ + + /* return isDateType ? ( + + ) : */ + return optionSetId ? ( { expect(row).toHaveTextContent('medium') }) - test('is hidden when the typed text exactly matches an existing option', () => { - const options = [{ value: 'High' }, { value: 'Low' }] - renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + test('stays shown and keeps live-applying even when the typed text exactly matches an existing option', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options: [{ value: 'High' }, { value: 'Low' }], + }) openPopover('Legend') fireEvent.change(getInput('Legend'), { target: { value: 'High' }, }) expect( - screen.queryByTestId('data-table-column-filter-custom-Legend') - ).not.toBeInTheDocument() + screen.getByTestId('data-table-column-filter-custom-Legend') + ).toBeInTheDocument() + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: 'High', + }) }) test('clearing the typed text clears an already-applied custom filter live', () => { diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx new file mode 100644 index 0000000000..a39cdc00a4 --- /dev/null +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -0,0 +1,115 @@ +import { render, act } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { useColumnWidths } from '../useColumnWidths.js' + +let rafCallbacks + +beforeEach(() => { + rafCallbacks = [] + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + jest.spyOn( + HTMLElement.prototype, + 'getBoundingClientRect' + ).mockImplementation(function () { + return { width: Number(this.dataset.width) || 0 } + }) +}) + +afterEach(() => { + global.requestAnimationFrame.mockRestore() + HTMLElement.prototype.getBoundingClientRect.mockRestore() +}) + +const flushRaf = () => { + while (rafCallbacks.length) { + rafCallbacks.shift()() + } +} + +// A stable reference: the hook re-measures whenever `headers` changes +// identity, so passing a literal from the test body would spuriously +// reset the measurement on every rerender +const HEADERS = [{ dataKey: 'a' }, { dataKey: 'b' }] + +const Harness = ({ availableWidth, error, widths, onColumnWidths }) => { + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers: HEADERS, + error, + }) + onColumnWidths(columnWidths) + return ( + + + + + +
{/* checkbox column, skipped */} + {widths.map((w, i) => ( + + ))} +
+ ) +} + +Harness.propTypes = { + widths: PropTypes.arrayOf(PropTypes.number).isRequired, + onColumnWidths: PropTypes.func.isRequired, + availableWidth: PropTypes.number, + error: PropTypes.bool, +} + +describe('useColumnWidths - MIN_COLUMN_WIDTH floor', () => { + it('floors a narrower-than-minimum measured column up to the minimum, leaving wider columns untouched', () => { + let latestWidths + act(() => { + render( + { + latestWidths = w + }} + /> + ) + }) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + }) + + it('the floored width stays the resize-clamp floor on a later shrink, instead of scaling down further', () => { + let latestWidths + const { rerender } = render( + { + latestWidths = w + }} + /> + ) + act(() => { + flushRaf() + }) + expect(latestWidths).toEqual([100, 150]) + + act(() => { + rerender( + { + latestWidths = w + }} + /> + ) + }) + expect(latestWidths).toEqual([100, 150]) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index bcfb69f92e..0f9c59ea97 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -551,12 +551,17 @@ describe('useTableData headers', () => { { name: 'Org unit', dataKey: 'ouname', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { - name: 'Event time', + name: 'Event date', dataKey: 'eventdate', type: 'date', renderer: 'renderdate', }, - { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, + { + name: 'Last updated on', + dataKey: 'lastupdated', + type: 'date', + renderer: 'renderdate', + }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 9c636d8c7b..416b4a55d8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -165,9 +165,27 @@ th.hovered { .noSupport { position: absolute; - top: 50%; left: 50%; - transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--spacers-dp8); color: var(--colors-grey600); font-style: italic; + font-size: 12px; +} + +.editLayerLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.editLayerLink:hover { + color: var(--colors-blue700); } diff --git a/src/components/datatable/styles/ErrorBoundary.module.css b/src/components/datatable/styles/ErrorBoundary.module.css new file mode 100644 index 0000000000..816db97bd3 --- /dev/null +++ b/src/components/datatable/styles/ErrorBoundary.module.css @@ -0,0 +1,5 @@ +.message { + color: var(--colors-grey600); + font-style: italic; + font-size: 12px; +} diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css index 83a175c0ee..2541b751cf 100644 --- a/src/components/datatable/styles/FilterInput.module.css +++ b/src/components/datatable/styles/FilterInput.module.css @@ -177,3 +177,28 @@ .multiSelectPopover .highlighted { background: var(--colors-grey100); } + +.treeRow { + display: flex; + align-items: center; + width: 100%; +} + +.expandButton { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + flex-shrink: 0; + background: transparent; + color: var(--colors-grey700); + cursor: pointer; +} + +.expandButtonPlaceholder { + width: 16px; + flex-shrink: 0; +} diff --git a/src/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js index 2644c6c86c..c6a857c636 100644 --- a/src/components/datatable/useColumnWidths.js +++ b/src/components/datatable/useColumnWidths.js @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' +const MIN_COLUMN_WIDTH = 100 + export const useColumnWidths = ({ availableWidth, headers, error }) => { const headerRowRef = useRef(null) const minColumnWidthsRef = useRef([]) @@ -23,7 +25,9 @@ export const useColumnWidths = ({ availableWidth, headers, error }) => { for (const cell of dataCells) { const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) + measuredColumnWidths.push( + Math.max(MIN_COLUMN_WIDTH, Math.floor(rect.width)) + ) } minColumnWidthsRef.current = measuredColumnWidths diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index a6ffca021f..c9f1b7f4af 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { - formatDatetime, + formatDate, formatCoordinate, formatValueForDisplay, } from '../../../util/helpers.js' @@ -177,8 +177,8 @@ const EventPopup = ({ )} {occurredAt && ( - {i18n.t('Event time')} - {formatDatetime(occurredAt)} + {i18n.t('Event date')} + {formatDate(occurredAt)} )} diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index a89d40516f..0bac15662d 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -12,3 +12,7 @@ export const RENDERER_DATE = 'renderdate' export const TYPE_NUMBER = 'number' export const TYPE_STRING = 'string' export const TYPE_DATE = 'date' +export const TYPE_DATETIME = 'datetime' +export const TYPE_TIME = 'time' + +export const DATE_GROUPS_GRANULARITY = 'date-groups' diff --git a/src/constants/valueTypes.js b/src/constants/valueTypes.js index 15831f9da8..d9dbf398ce 100644 --- a/src/constants/valueTypes.js +++ b/src/constants/valueTypes.js @@ -32,6 +32,9 @@ export const dateValueTypes = ['DATE', 'AGE'] // Date-time value types export const datetimeValueTypes = ['DATETIME'] +// Time-only value types +export const timeValueTypes = ['TIME'] + // Coordinate value types export const coordinateValueTypes = ['COORDINATE'] diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index 9f376df8ea..5dc7069838 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,8 @@ -import { SENTINEL_ANY_VALUE } from '../../constants/dataTable.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { @@ -109,6 +113,96 @@ describe('filterData', () => { const filters = { a: [SENTINEL_ANY_VALUE, ''] } expect(filterData(data, filters)).toEqual(data) }) + + describe('date-group filter ({ granularity, prefixes })', () => { + const data = [ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + { a: null }, + ] + + it('matches every row under a single year prefix', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('matches only the selected day prefix', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-16'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-16 03:00:00.0' }, + ]) + }) + + it('ORs across prefixes of different granularities', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: ['2023-05-15', '2024'], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not treat an empty prefix list as "match nothing" (mirrors the empty-array convention: match everything)', () => { + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: [] }, + } + expect(filterData(data, filters)).toEqual(data) + }) + + it('SENTINEL_NO_VALUE only matches null/missing values, never startsWith("")-matching everything', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_NO_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([{ a: null }]) + }) + + it('SENTINEL_ANY_VALUE matches every non-blank value', () => { + const filters = { + a: { + granularity: DATE_GROUPS_GRANULARITY, + prefixes: [SENTINEL_ANY_VALUE], + }, + } + expect(filterData(data, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0' }, + { a: '2023-05-16 03:00:00.0' }, + { a: '2024-01-01 00:00:00.0' }, + ]) + }) + + it('does not throw and combines (AND) correctly with an unrelated string filter on another field', () => { + const mixedData = [ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + { a: '2023-05-16 00:00:00.0', b: 'banana' }, + { a: '2024-01-01 00:00:00.0', b: 'apple' }, + ] + const filters = { + a: { granularity: DATE_GROUPS_GRANULARITY, prefixes: ['2023'] }, + b: 'apple', + } + expect(filterData(mixedData, filters)).toEqual([ + { a: '2023-05-15 00:00:00.0', b: 'apple' }, + ]) + }) + }) }) describe('filterByGlobalSearch', () => { diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js index 747d6063fe..62440eb572 100644 --- a/src/util/__tests__/filterInput.spec.js +++ b/src/util/__tests__/filterInput.spec.js @@ -4,7 +4,6 @@ import { getFilteredOptions, getPopoverWidth, getSelectedAndAppliedString, - hasMatchingOptionLabel, measureMaxTextWidth, toHighlightedIndex, toOptionIndex, @@ -141,27 +140,6 @@ describe('getPopoverWidth', () => { }) }) -describe('hasMatchingOptionLabel', () => { - const options = [{ value: 'a' }, { value: 'b' }] - const resolveLabel = (v) => ({ a: 'Apple', b: 'Banana' }[v]) - - it('is true when some option resolves to exactly the given text', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'apple')).toBe( - true - ) - }) - - it('is false for a partial match', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'app')).toBe(false) - }) - - it('is false when no option matches', () => { - expect(hasMatchingOptionLabel(options, resolveLabel, 'cherry')).toBe( - false - ) - }) -}) - describe('getCyclicIndex', () => { it('moves forward within range', () => { expect(getCyclicIndex(0, 3, 1)).toBe(1) diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 97a4e078e2..b130fe4d24 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,3 +1,4 @@ +import { RENDERER_DATE } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -12,6 +13,9 @@ import { getHeadersForLayer, TYPE_NUMBER, TYPE_STRING, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../tableHeaders.js' jest.mock('../../components/map/MapApi.js', () => ({ @@ -99,6 +103,43 @@ describe('getHeadersForLayer - event', () => { (h) => h.dataKey === 'w75KJ2mc4zz' ) expect(ageHeader.type).toBe(TYPE_NUMBER) + const eventdateHeader = result.headers.find( + (h) => h.dataKey === 'eventdate' + ) + expect(eventdateHeader.type).toBe(TYPE_DATE) + }) + + test('custom DATE/DATETIME/TIME/AGE fields get their matching type, option-set-backed fields stay TYPE_STRING', () => { + const layerHeaders = [ + { name: 'w75KJ2mc4zz', column: 'Date of birth', valueType: 'DATE' }, + { + name: 'zDhUuAYrxNC', + column: 'Registered at', + valueType: 'DATETIME', + }, + { name: 'oZg33kd9taw', column: 'Visit time', valueType: 'TIME' }, + { name: 'a1b2c3d4e5f', column: 'Age', valueType: 'AGE' }, + { + name: 'b2c3d4e5f6a', + column: 'Gender', + valueType: 'TEXT', + optionSet: { id: 'os1' }, + }, + ] + const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) + expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE) + expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined() }) test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { @@ -166,6 +207,34 @@ describe('getHeadersForLayer - tracked entity', () => { ) expect(nameHeader.type).toBe(TYPE_STRING) }) + + test('custom DATE/DATETIME/TIME attributes get their matching type', () => { + const layerHeaders = [ + { + name: 'Date of birth', + dataKey: 'w75KJ2mc4zz', + valueType: 'DATE', + }, + { + name: 'Enrolled at', + dataKey: 'zDhUuAYrxNC', + valueType: 'DATETIME', + }, + { name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' }, + ] + const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { + layerHeaders, + }) + const headerFor = (dataKey) => + result.headers.find((h) => h.dataKey === dataKey) + const typeOf = (dataKey) => headerFor(dataKey).type + expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) + expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) + expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) + expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) + expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE) + }) }) describe('getHeadersForLayer - earth engine', () => { diff --git a/src/util/filter.js b/src/util/filter.js index 7189e2568c..ca6fb5c7ef 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,8 +1,32 @@ import { SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, } from '../constants/dataTable.js' +// Distinguishes a date-groups filter +export const isDateGroupFilter = (filter) => + filter != null && + typeof filter === 'object' && + !Array.isArray(filter) && + filter.granularity === DATE_GROUPS_GRANULARITY + +export const dateGroupFilter = (value, { prefixes }) => { + if (!prefixes?.length) { + return true + } + const stringValue = value == null ? SENTINEL_NO_VALUE : String(value) + return prefixes.some((prefix) => { + if (prefix === SENTINEL_NO_VALUE) { + return stringValue === SENTINEL_NO_VALUE + } + if (prefix === SENTINEL_ANY_VALUE) { + return stringValue !== SENTINEL_NO_VALUE + } + return stringValue.startsWith(prefix) + }) +} + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -20,6 +44,10 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (isDateGroupFilter(filter)) { + return dateGroupFilter(value, filter) + } + if (Array.isArray(filter)) { // Multi-select: OR match against the raw stored value const stringValue = diff --git a/src/util/filterInput.js b/src/util/filterInput.js index db972db347..bb7773b636 100644 --- a/src/util/filterInput.js +++ b/src/util/filterInput.js @@ -6,6 +6,11 @@ const POPOVER_ROW_NON_LABEL_WIDTH = 56 const MIN_POPOVER_WIDTH = 140 const MAX_POPOVER_WIDTH = 280 +// Shared between FilterInput.jsx's SearchableFilterPopover and +// DateGroupFilterInput.jsx's tree - both virtualize a list of fixed-height rows +export const OPTION_ROW_HEIGHT = 28 +export const MAX_LIST_HEIGHT = 260 + export const getSelectedAndAppliedString = (filterValue) => ({ selected: Array.isArray(filterValue) ? filterValue : [], appliedString: typeof filterValue === 'string' ? filterValue : '', @@ -68,11 +73,6 @@ export const getPopoverWidth = (maxLabelWidth) => MAX_POPOVER_WIDTH ) -export const hasMatchingOptionLabel = (options, resolveLabel, normalizedText) => - options.some( - ({ value }) => resolveLabel(value).toLowerCase() === normalizedText - ) - export const getCyclicIndex = (current, total, delta) => total ? (current + delta + total) % total : -1 diff --git a/src/util/helpers.js b/src/util/helpers.js index d69c0d2c61..3ada5f9837 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -167,7 +167,7 @@ const formatBoolean = (value) => { } // Formats a DHIS2 date string value -const formatDate = (value) => { +export const formatDate = (value) => { const datePattern = /^(\d{4}-\d{2}-\d{2})/ const match = value.match(datePattern) return match ? match[1] : value diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 3c42d4ac88..38f3afa343 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -6,6 +6,8 @@ import { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../constants/dataTable.js' import { EVENT_LAYER, @@ -16,13 +18,42 @@ import { GEOJSON_URL_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' -import { numberValueTypes } from '../constants/valueTypes.js' +import { + numberValueTypes, + dateValueTypes, + datetimeValueTypes, + timeValueTypes, +} from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' import { getRoundToPrecisionFn, getPrecision } from './numbers.js' import { isValidUid } from './uid.js' -export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE } +export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } + +const getCustomFieldType = (valueType, hasOptionSet) => { + if (hasOptionSet) { + return TYPE_STRING + } + if (numberValueTypes.includes(valueType)) { + return TYPE_NUMBER + } + if (dateValueTypes.includes(valueType)) { + return TYPE_DATE + } + if (datetimeValueTypes.includes(valueType)) { + return TYPE_DATETIME + } + if (timeValueTypes.includes(valueType)) { + return TYPE_TIME + } + return TYPE_STRING +} + +const DATE_LIKE_TYPES = [TYPE_DATE, TYPE_DATETIME, TYPE_TIME] + +const getCustomFieldRenderer = (type) => + DATE_LIKE_TYPES.includes(type) ? RENDERER_DATE : undefined const NAME = 'name' const ID = 'id' @@ -61,7 +92,7 @@ const defaultFieldsMap = () => ({ type: TYPE_STRING, }, [EVENTDATE]: { - name: i18n.t('Event time'), + name: i18n.t('Event date'), dataKey: EVENTDATE, type: TYPE_DATE, renderer: RENDERER_DATE, @@ -163,15 +194,16 @@ const getEventHeaders = ({ const customFields = layerHeaders .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ - name, - dataKey, - type: - !optionSet && numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - optionSet: optionSet || null, - })) + .map(({ name: dataKey, column: name, valueType, optionSet }) => { + const type = getCustomFieldType(valueType, !!optionSet) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + optionSet: optionSet || null, + } + }) customFields.push( defaultFieldsMap()[TYPE], @@ -217,13 +249,15 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { const customFields = layerHeaders .filter(({ dataKey }) => isValidUid(dataKey)) - .map(({ name, dataKey, valueType }) => ({ - name, - dataKey, - type: numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, - })) + .map(({ name, dataKey, valueType }) => { + const type = getCustomFieldType(valueType, false) + return { + name, + dataKey, + type, + renderer: getCustomFieldRenderer(type), + } + }) customFields.push(...getStyleHeaders({ hasColor: true })) diff --git a/src/util/time.js b/src/util/time.js index e0d6821109..196130aecd 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -3,7 +3,7 @@ import i18n from '@dhis2/d2-i18n' const DEFAULT_LOCALE = 'en' // BCP 47 locale format -const dateLocale = (locale) => +export const dateLocale = (locale) => locale?.includes('_') ? locale.replaceAll('_', '-') : locale /** From 10304a3f40857d9977d6f93fff6f2c181df16f4d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 24 Jul 2026 12:26:02 +0200 Subject: [PATCH 44/47] chore: sonarqube fix --- .../__tests__/useColumnWidths.spec.jsx | 20 +++++++++---------- src/util/tableHeaders.js | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/datatable/__tests__/useColumnWidths.spec.jsx b/src/components/datatable/__tests__/useColumnWidths.spec.jsx index a39cdc00a4..66978648c6 100644 --- a/src/components/datatable/__tests__/useColumnWidths.spec.jsx +++ b/src/components/datatable/__tests__/useColumnWidths.spec.jsx @@ -66,17 +66,15 @@ Harness.propTypes = { describe('useColumnWidths - MIN_COLUMN_WIDTH floor', () => { it('floors a narrower-than-minimum measured column up to the minimum, leaving wider columns untouched', () => { let latestWidths - act(() => { - render( - { - latestWidths = w - }} - /> - ) - }) + render( + { + latestWidths = w + }} + /> + ) act(() => { flushRaf() }) diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 38f3afa343..7e6be114cf 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -50,10 +50,10 @@ const getCustomFieldType = (valueType, hasOptionSet) => { return TYPE_STRING } -const DATE_LIKE_TYPES = [TYPE_DATE, TYPE_DATETIME, TYPE_TIME] +const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) const getCustomFieldRenderer = (type) => - DATE_LIKE_TYPES.includes(type) ? RENDERER_DATE : undefined + DATE_LIKE_TYPES.has(type) ? RENDERER_DATE : undefined const NAME = 'name' const ID = 'id' From 41f87de71b1a60ed5c9660ce520bf1d6e7784b78 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 7 Sep 2026 10:36:24 +0200 Subject: [PATCH 45/47] chore: regenerate i18n/en.pot after PR5 rebase Same drift as prior rebases: the merge driver's timestamp-only resolutions left message entries stale relative to the merged source. Re-run the extraction to bring it back in sync. --- i18n/en.pot | 88 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 5f7fed09f9..9375e4be98 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-04T12:03:11.272Z\n" -"PO-Revision-Date: 2026-09-04T12:03:11.272Z\n" +"POT-Creation-Date: 2026-09-07T08:34:02.249Z\n" +"PO-Revision-Date: 2026-09-07T08:34:02.249Z\n" msgid "2020" msgstr "2020" @@ -167,6 +167,9 @@ msgstr "Sort by Selected" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" +msgid "Edit layer" +msgstr "Edit layer" + msgid "Something went wrong" msgstr "Something went wrong" @@ -248,6 +251,12 @@ msgstr "Zoom to selected features" msgid "Zoom to filtered features" msgstr "Zoom to filtered features" +msgid "Event details aren't available while this layer is clustered on the server" +msgstr "Event details aren't available while this layer is clustered on the server" + +msgid "Show event details" +msgstr "Show event details" + msgid "No features match your filters" msgstr "No features match your filters" @@ -302,9 +311,6 @@ msgstr "{{total}} rows" msgid "Show only features in current map view" msgstr "Show only features in current map view" -msgid "Data table is not supported when events are grouped on the server." -msgstr "Data table is not supported when events are grouped on the server." - msgid "No valid data was found for the current layer configuration." msgstr "No valid data was found for the current layer configuration." @@ -318,33 +324,6 @@ msgstr "" msgid "No valid data fields were found for this layer." msgstr "No valid data fields were found for this layer." -msgid "Id" -msgstr "Id" - -msgid "Level" -msgstr "Level" - -msgid "Parent" -msgstr "Parent" - -msgid "Type" -msgstr "Type" - -msgid "Legend" -msgstr "Legend" - -msgid "Range" -msgstr "Range" - -msgid "Org unit" -msgstr "Org unit" - -msgid "Org unit boundary" -msgstr "Org unit boundary" - -msgid "Event time" -msgstr "Event time" - msgid "Loading Earth Engine data…" msgstr "Loading Earth Engine data…" @@ -833,9 +812,6 @@ msgstr "Open in Data Visualizer app" msgid "Download data" msgstr "Download data" -msgid "Edit layer" -msgstr "Edit layer" - msgid "Duplicate layer" msgstr "Duplicate layer" @@ -909,6 +885,9 @@ msgstr[1] "{{n}} org units without coordinates" msgid "Selected org units: No coordinates found" msgstr "Selected org units: No coordinates found" +msgid "External layer definition not found, showing last known settings" +msgstr "External layer definition not found, showing last known settings" + msgid "Error" msgstr "Error" @@ -940,12 +919,18 @@ msgstr "Could not retrieve event data" msgid "Organisation unit" msgstr "Organisation unit" +msgid "Event date" +msgstr "Event date" + msgid "Groups" msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" +msgid "Level" +msgstr "Level" + msgid "Not set" msgstr "Not set" @@ -1071,6 +1056,9 @@ msgstr "No data found for this period." msgid "Image of the organisation unit" msgstr "Image of the organisation unit" +msgid "Parent" +msgstr "Parent" + msgid "Code" msgstr "Code" @@ -1190,6 +1178,9 @@ msgstr "Click to unpin legend" msgid "Click to pin legend" msgstr "Click to pin legend" +msgid "Legend" +msgstr "Legend" + msgid "Hide layer" msgstr "Hide layer" @@ -2065,6 +2056,33 @@ msgstr "Facility" msgid "GroupSet used for styling was not found" msgstr "GroupSet used for styling was not found" +msgid "Id" +msgstr "Id" + +msgid "Type" +msgstr "Type" + +msgid "Range" +msgstr "Range" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Org unit boundary" +msgstr "Org unit boundary" + +msgid "Group" +msgstr "Group" + +msgid "Icon" +msgstr "Icon" + +msgid "Current period" +msgstr "Current period" + +msgid "Value ({{period}})" +msgstr "Value ({{period}})" + msgid "Start date is invalid" msgstr "Start date is invalid" From 47ff7f38175a74304649e8433d5d94a2dacbd9cc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 7 Sep 2026 11:27:58 +0200 Subject: [PATCH 46/47] refactor: extract usePanelHeights hook from BottomPanel isCollapsed was the only local-state input to getPanelHeights, with everything else (window height, dataTableHeight, CSS vars) pulled straight from context - move that plumbing into its own hook per review feedback. --- src/components/datatable/BottomPanel.jsx | 20 ++++---------------- src/components/datatable/usePanelHeights.js | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 src/components/datatable/usePanelHeights.js diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index a1508c90cd..85e78eae04 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -16,12 +16,7 @@ import { } from '../../actions/dataTable.js' import useDebouncedValue from '../../hooks/useDebouncedValue.js' import useKeyDown from '../../hooks/useKeyDown.js' -import { - getPanelHeights, - hasActiveDataTableFilters, -} from '../../util/dataTable.js' -import { getCssVar } from '../../util/helpers.js' -import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' +import { hasActiveDataTableFilters } from '../../util/dataTable.js' import ActiveLayerControl from './controls/ActiveLayerControl.jsx' import ClearFiltersControl from './controls/ClearFiltersControl.jsx' import CloseControl from './controls/CloseControl.jsx' @@ -35,12 +30,12 @@ import ShowInViewControl from './controls/ShowInViewControl.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' import styles from './styles/BottomPanel.module.css' +import { usePanelHeights } from './usePanelHeights.js' const MIN_HEIGHT = 50 const EMPTY_FILTERS = {} const BottomPanel = () => { - const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) const activeLayerId = useSelector((state) => state.dataTable) const activeLayer = useSelector((state) => state.map.mapViews.find((l) => l.id === activeLayerId) @@ -53,7 +48,6 @@ const BottomPanel = () => { const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() - const { height } = useWindowDimensions() const panelRef = useRef(null) const isDraggingRef = useRef(false) const preDragCollapsedRef = useRef(false) @@ -72,14 +66,8 @@ const BottomPanel = () => { showOnlyFeaturesInView, }) - const { maxHeight, collapsedHeight, displayHeight } = getPanelHeights({ - windowHeight: height, - dataTableHeight, - isCollapsed, - headerHeight: getCssVar('--header-height'), - toolbarHeight: getCssVar('--toolbar-height'), - controlsHeight: getCssVar('--data-table-controls-height'), - }) + const { maxHeight, collapsedHeight, displayHeight } = + usePanelHeights(isCollapsed) const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), diff --git a/src/components/datatable/usePanelHeights.js b/src/components/datatable/usePanelHeights.js new file mode 100644 index 0000000000..22ef1da263 --- /dev/null +++ b/src/components/datatable/usePanelHeights.js @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux' +import { getPanelHeights } from '../../util/dataTable.js' +import { getCssVar } from '../../util/helpers.js' +import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' + +export const usePanelHeights = (isCollapsed) => { + const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) + const { height } = useWindowDimensions() + + return getPanelHeights({ + windowHeight: height, + dataTableHeight, + isCollapsed, + headerHeight: getCssVar('--header-height'), + toolbarHeight: getCssVar('--toolbar-height'), + controlsHeight: getCssVar('--data-table-controls-height'), + }) +} From 078d2710397c4fd9c83685d8261abf8734b8257d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 7 Sep 2026 11:56:24 +0200 Subject: [PATCH 47/47] refactor: extract per-column cell rendering into RowCells component The itemContent render prop had grown into a large inline function mixing checkbox-cell and per-column cell rendering (color/icon/date renderers, pinning). Extract it into its own component, wrapped in a memoized callback, matching the existing fixedHeaderContent pattern. --- src/components/datatable/DataTable.jsx | 165 +++++------------- src/components/datatable/RowCells.jsx | 145 +++++++++++++++ .../datatable/__tests__/RowCells.spec.jsx | 122 +++++++++++++ 3 files changed, 308 insertions(+), 124 deletions(-) create mode 100644 src/components/datatable/RowCells.jsx create mode 100644 src/components/datatable/__tests__/RowCells.spec.jsx diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index a6d9691cc7..c03403a3f0 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -1,7 +1,6 @@ import i18n from '@dhis2/d2-i18n' import { DataTableRow, - DataTableCell, DataTableColumnHeader, ComponentCover, CenteredContent, @@ -30,12 +29,7 @@ import { import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, - RENDERER_COLOR, - RENDERER_ICON, - RENDERER_DATE, - TYPE_DATE, } from '../../constants/dataTable.js' -import { isDarkColor } from '../../util/colors.js' import { buildFeatureIndex, getNextSorting, @@ -45,8 +39,6 @@ import { isFilterable, shouldClearFeatureHighlight, } from '../../util/dataTable.js' -import { formatDate, formatDatetime } from '../../util/helpers.js' -import { formatWithSeparator } from '../../util/numbers.js' import { getPinnedCellProps, getPinnedCount, @@ -56,6 +48,7 @@ import { import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' +import RowCells from './RowCells.jsx' import SelectionFilterButton from './SelectionFilterButton.jsx' import styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' @@ -538,6 +531,45 @@ const Table = ({ ] ) + const onToggleSelection = useCallback( + (rowId) => dispatch(toggleFeatureSelection(rowId, layer.id)), + [dispatch, layer.id] + ) + + const itemContent = useCallback( + (_, row) => ( + + ), + [ + visibleHeaders, + selectedIdSet, + feature, + layer.id, + isCheckboxColumnPinned, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + rendererByDataKey, + typeByDataKey, + keyAnalysisDigitGroupSeparator, + onToggleSelection, + ] + ) + if (error) { return (

@@ -564,122 +596,7 @@ const Table = ({ computeItemKey={computeItemKey} increaseViewportBy={VIEWPORT_OVERSCAN} fixedHeaderContent={fixedHeaderContent} - itemContent={(_, row) => { - const rowId = getRowId(row) - const isSelected = !!rowId && selectedIdSet.has(rowId) - const isHovered = - !!rowId && - feature?.id === rowId && - feature?.layerId === layer.id - - const cellsByDataKey = new Map( - row.map((cell) => [cell.dataKey, cell]) - ) - - return ( - <> - - - rowId && - dispatch( - toggleFeatureSelection( - rowId, - layer.id - ) - ) - } - onClick={(e) => e.stopPropagation()} - /> - - {visibleHeaders.map(({ dataKey }, index) => { - const cell = cellsByDataKey.get(dataKey) - if (!cell) { - return null - } - const { value, align } = cell - const { fixed, left, width, isLastPinned } = - getPinnedCellProps(dataKey, index, { - pinnedLeftOffsets, - pinnedColumnCount, - columnWidths, - }) - const renderer = rendererByDataKey.get(dataKey) - const isColorCell = renderer === RENDERER_COLOR - const isIconCell = renderer === RENDERER_ICON - const isDateCell = renderer === RENDERER_DATE - const isDateOnlyCell = - typeByDataKey.get(dataKey) === TYPE_DATE - return ( - - {isColorCell && value?.toLowerCase()} - {isIconCell && value && ( - { - e.target.style.visibility = - 'hidden' - }} - /> - )} - {isDateCell && - value && - (isDateOnlyCell - ? formatDate(value) - : formatDatetime(value))} - {!isColorCell && - !isIconCell && - !isDateCell && - formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - - ) - })} - - ) - }} + itemContent={itemContent} /> {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( diff --git a/src/components/datatable/RowCells.jsx b/src/components/datatable/RowCells.jsx new file mode 100644 index 0000000000..541298e884 --- /dev/null +++ b/src/components/datatable/RowCells.jsx @@ -0,0 +1,145 @@ +import { DataTableCell } from '@dhis2/ui' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + TYPE_DATE, +} from '../../constants/dataTable.js' +import { isDarkColor } from '../../util/colors.js' +import { getRowId } from '../../util/dataTable.js' +import { formatDate, formatDatetime } from '../../util/helpers.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { getPinnedCellProps } from '../../util/tableColumns.js' +import styles from './styles/DataTable.module.css' + +const RowCells = ({ + row, + visibleHeaders, + selectedIdSet, + hoveredFeature, + layerId, + isCheckboxColumnPinned, + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + rendererByDataKey, + typeByDataKey, + keyAnalysisDigitGroupSeparator, + onToggleSelection, +}) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + hoveredFeature?.id === rowId && + hoveredFeature?.layerId === layerId + + const cellsByDataKey = new Map(row.map((cell) => [cell.dataKey, cell])) + + return ( + <> + + rowId && onToggleSelection(rowId)} + onClick={(e) => e.stopPropagation()} + /> + + {visibleHeaders.map(({ dataKey }, index) => { + const cell = cellsByDataKey.get(dataKey) + if (!cell) { + return null + } + const { value, align } = cell + const { fixed, left, width, isLastPinned } = getPinnedCellProps( + dataKey, + index, + { + pinnedLeftOffsets, + pinnedColumnCount, + columnWidths, + } + ) + const renderer = rendererByDataKey.get(dataKey) + const isColorCell = renderer === RENDERER_COLOR + const isIconCell = renderer === RENDERER_ICON + const isDateCell = renderer === RENDERER_DATE + const isDateOnlyCell = typeByDataKey.get(dataKey) === TYPE_DATE + return ( + + {isColorCell && value?.toLowerCase()} + {isIconCell && value && ( + { + e.target.style.visibility = 'hidden' + }} + /> + )} + {isDateCell && + value && + (isDateOnlyCell + ? formatDate(value) + : formatDatetime(value))} + {!isColorCell && + !isIconCell && + !isDateCell && + formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + + ) + })} + + ) +} + +RowCells.propTypes = { + columnWidths: PropTypes.array.isRequired, + isCheckboxColumnPinned: PropTypes.bool.isRequired, + pinnedColumnCount: PropTypes.number.isRequired, + pinnedLeftOffsets: PropTypes.object.isRequired, + rendererByDataKey: PropTypes.instanceOf(Map).isRequired, + row: PropTypes.array.isRequired, + selectedIdSet: PropTypes.instanceOf(Set).isRequired, + typeByDataKey: PropTypes.instanceOf(Map).isRequired, + visibleHeaders: PropTypes.array.isRequired, + onToggleSelection: PropTypes.func.isRequired, + hoveredFeature: PropTypes.object, + keyAnalysisDigitGroupSeparator: PropTypes.string, + layerId: PropTypes.string, +} + +export default RowCells diff --git a/src/components/datatable/__tests__/RowCells.spec.jsx b/src/components/datatable/__tests__/RowCells.spec.jsx new file mode 100644 index 0000000000..7206646534 --- /dev/null +++ b/src/components/datatable/__tests__/RowCells.spec.jsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { + RENDERER_COLOR, + RENDERER_ICON, + RENDERER_DATE, + TYPE_DATE, +} from '../../../constants/dataTable.js' +import RowCells from '../RowCells.jsx' + +const NAME_HEADER = { dataKey: 'name' } + +const defaultProps = { + visibleHeaders: [NAME_HEADER], + selectedIdSet: new Set(), + hoveredFeature: null, + layerId: 'layer1', + isCheckboxColumnPinned: false, + pinnedLeftOffsets: {}, + pinnedColumnCount: 0, + columnWidths: [], + rendererByDataKey: new Map(), + typeByDataKey: new Map(), + keyAnalysisDigitGroupSeparator: undefined, + onToggleSelection: jest.fn(), +} + +const renderRow = (row, overrides = {}) => + render( + + + + + + +
+ ) + +describe('RowCells', () => { + it('renders a plain formatted value cell by default', () => { + renderRow([ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: 'Bo' }, + ]) + expect(screen.getByText('Bo')).toBeInTheDocument() + }) + + it('renders a color cell as a lowercased swatch value with a background color', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '#FF0000' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_COLOR]]) } + ) + const cell = screen.getByText('#ff0000') + expect(cell).toBeInTheDocument() + expect(cell.closest('td')).toHaveStyle({ + backgroundColor: '#FF0000', + }) + }) + + it('renders an icon cell as an image', () => { + const { container } = renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: 'https://example.com/icon.png' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_ICON]]) } + ) + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.com/icon.png' + ) + }) + + it('renders a date-only cell formatted as just the date', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '2024-03-15T10:30:00' }, + ], + { + rendererByDataKey: new Map([['name', RENDERER_DATE]]), + typeByDataKey: new Map([['name', TYPE_DATE]]), + } + ) + expect(screen.getByText('2024-03-15')).toBeInTheDocument() + }) + + it('renders a datetime cell formatted with the time', () => { + renderRow( + [ + { dataKey: 'id', value: 'row1' }, + { dataKey: 'name', value: '2024-03-15T10:30:00' }, + ], + { rendererByDataKey: new Map([['name', RENDERER_DATE]]) } + ) + expect(screen.getByText('2024-03-15 10:30')).toBeInTheDocument() + }) + + it('checks the checkbox when the row id is in selectedIdSet', () => { + renderRow([{ dataKey: 'id', value: 'row1' }], { + selectedIdSet: new Set(['row1']), + }) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('calls onToggleSelection with the row id when the checkbox is clicked', () => { + const onToggleSelection = jest.fn() + renderRow([{ dataKey: 'id', value: 'row1' }], { onToggleSelection }) + + screen.getByRole('checkbox').click() + + expect(onToggleSelection).toHaveBeenCalledWith('row1') + }) + + it('skips headers with no matching cell in the row', () => { + renderRow([{ dataKey: 'id', value: 'row1' }]) + expect(screen.queryByText('Bo')).not.toBeInTheDocument() + }) +})