diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js
index 0fbf8c5cec..ea24c6b490 100644
--- a/src/constants/actionTypes.js
+++ b/src/constants/actionTypes.js
@@ -45,6 +45,7 @@ export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW'
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'
/* DATA FILTER */
export const DATA_FILTER_SET = 'DATA_FILTER_SET'
diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js
index da3b847c16..e5d517a341 100644
--- a/src/loaders/eventLoader.js
+++ b/src/loaders/eventLoader.js
@@ -161,6 +161,7 @@ const loadEventLayer = async ({
unclassifiedLegend: unclassifiedLegendFromConfig,
noDataLegend: noDataLegendFromConfig,
labelDataItem,
+ dataTableColumnConfig,
} = parseJsonConfig(config.config)
if (countFeaturesWithoutCoordinates) {
config.countFeaturesWithoutCoordinates = true
@@ -196,6 +197,9 @@ const loadEventLayer = async ({
if (noDataLegendFromConfig) {
config.noDataLegend = noDataLegendFromConfig
}
+ if (dataTableColumnConfig) {
+ config.dataTableColumnConfig = dataTableColumnConfig
+ }
if (config.noDataColor) {
config.noDataLegend = {
...noDataLegendFromConfig,
diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js
index db0be8196b..ef8e07ed77 100644
--- a/src/loaders/facilityLoader.js
+++ b/src/loaders/facilityLoader.js
@@ -65,14 +65,20 @@ const facilityLoader = async ({
// Config parsing
// -----
- const { countFeaturesWithoutCoordinates, unclassifiedLegend } =
- parseJsonConfig(config.config)
+ const {
+ countFeaturesWithoutCoordinates,
+ unclassifiedLegend,
+ dataTableColumnConfig,
+ } = parseJsonConfig(config.config)
if (countFeaturesWithoutCoordinates) {
config.countFeaturesWithoutCoordinates = true
}
if (unclassifiedLegend) {
config.unclassifiedLegend = unclassifiedLegend
}
+ if (dataTableColumnConfig) {
+ config.dataTableColumnConfig = dataTableColumnConfig
+ }
delete config.config
// Data loading
diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js
index 2d96c21922..ed26dbce8a 100644
--- a/src/loaders/geoJsonUrlLoader.js
+++ b/src/loaders/geoJsonUrlLoader.js
@@ -59,15 +59,19 @@ const geoJsonUrlLoader = async ({
let newConfig
let featureStyle
- // keep featureStyle property outside of config while in app
+ let dataTableColumnConfig
+ // 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)
featureStyle = { ...newConfig.featureStyle } || EMPTY_FEATURE_STYLE
+ dataTableColumnConfig = newConfig.dataTableColumnConfig
delete newConfig.featureStyle
+ delete newConfig.dataTableColumnConfig
} else {
newConfig = { ...config }
featureStyle = layer.featureStyle || EMPTY_FEATURE_STYLE
+ dataTableColumnConfig = layer.dataTableColumnConfig
}
let geoJson
@@ -129,6 +133,7 @@ const geoJsonUrlLoader = async ({
keyAnalysisDigitGroupSeparator,
config: newConfig,
featureStyle,
+ dataTableColumnConfig,
isLoaded: true,
isLoading: false,
isExpanded: true,
diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js
index 64f2711f35..1df10a1527 100644
--- a/src/loaders/orgUnitLoader.js
+++ b/src/loaders/orgUnitLoader.js
@@ -76,14 +76,20 @@ const orgUnitLoader = async ({
// Config parsing
// -----
- const { countFeaturesWithoutCoordinates, unclassifiedLegend } =
- parseJsonConfig(config.config)
+ const {
+ countFeaturesWithoutCoordinates,
+ unclassifiedLegend,
+ dataTableColumnConfig,
+ } = parseJsonConfig(config.config)
if (countFeaturesWithoutCoordinates) {
config.countFeaturesWithoutCoordinates = true
}
if (unclassifiedLegend) {
config.unclassifiedLegend = unclassifiedLegend
}
+ if (dataTableColumnConfig) {
+ config.dataTableColumnConfig = dataTableColumnConfig
+ }
delete config.config
// Data loading
diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index 4e4998c125..1e3dba4a08 100644
--- a/src/loaders/thematicLoader.js
+++ b/src/loaders/thematicLoader.js
@@ -85,6 +85,7 @@ const thematicLoader = async ({
legendIsolated,
unclassifiedLegend: unclassifiedLegendFromConfig,
noDataLegend: noDataLegendFromConfig,
+ dataTableColumnConfig,
} = parseJsonConfig(config.config)
if (countFeaturesWithoutCoordinates) {
config.countFeaturesWithoutCoordinates = true
@@ -101,6 +102,9 @@ const thematicLoader = async ({
if (noDataLegendFromConfig) {
config.noDataLegend = noDataLegendFromConfig
}
+ if (dataTableColumnConfig) {
+ config.dataTableColumnConfig = dataTableColumnConfig
+ }
if (config.noDataColor) {
config.noDataLegend = {
...noDataLegendFromConfig,
diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js
index 90ffe7b192..0a30a3bdce 100644
--- a/src/loaders/trackedEntityLoader.js
+++ b/src/loaders/trackedEntityLoader.js
@@ -115,7 +115,9 @@ export const parseJsonConfig = (config) => {
}
try {
- const { relationships, periodType } = JSON.parse(config.config)
+ const { relationships, periodType, dataTableColumnConfig } = JSON.parse(
+ config.config
+ )
if (relationships) {
config.relationshipType = relationships.type
@@ -127,6 +129,10 @@ export const parseJsonConfig = (config) => {
}
config.periodType = periodType
+
+ if (dataTableColumnConfig) {
+ config.dataTableColumnConfig = dataTableColumnConfig
+ }
} catch (e) {
// Malformed config JSON
}
diff --git a/src/reducers/map.js b/src/reducers/map.js
index 9f7828d331..d32818a36d 100644
--- a/src/reducers/map.js
+++ b/src/reducers/map.js
@@ -171,6 +171,16 @@ const layer = (state, action) => {
dataFilters: {},
}
+ case types.DATA_TABLE_COLUMN_CONFIG_SET:
+ if (state.id !== action.layerId) {
+ return state
+ }
+
+ return {
+ ...state,
+ dataTableColumnConfig: action.config,
+ }
+
case types.MAP_ALERTS_CLEAR:
return {
...state,
@@ -311,6 +321,7 @@ const map = (state = defaultState, action) => {
case types.DATA_FILTER_SET:
case types.DATA_FILTER_CLEAR:
case types.DATA_FILTERS_CLEAR_ALL:
+ case types.DATA_TABLE_COLUMN_CONFIG_SET:
case types.MAP_EARTH_ENGINE_VALUE_SHOW:
return {
...state,
diff --git a/src/util/__tests__/dataTable.spec.js b/src/util/__tests__/dataTable.spec.js
new file mode 100644
index 0000000000..94d7c6026d
--- /dev/null
+++ b/src/util/__tests__/dataTable.spec.js
@@ -0,0 +1,131 @@
+import {
+ getNextSorting,
+ getRowClickAction,
+ getRowId,
+ isFilterable,
+ shouldClearFeatureHighlight,
+} from '../dataTable.js'
+
+describe('shouldClearFeatureHighlight', () => {
+ test('clears when leaving to no element (cursor exits the window)', () => {
+ expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true)
+ })
+
+ test('does not clear when hovering to an adjacent row cell (TD)', () => {
+ expect(
+ shouldClearFeatureHighlight({ relatedTarget: { tagName: 'TD' } })
+ ).toBe(false)
+ })
+
+ test('clears when leaving to a non-TD element', () => {
+ expect(
+ shouldClearFeatureHighlight({ relatedTarget: { tagName: 'DIV' } })
+ ).toBe(true)
+ })
+})
+
+describe('getRowId', () => {
+ test('returns the id-keyed cell value when present', () => {
+ const row = [
+ { dataKey: 'name', value: 'Foo' },
+ { dataKey: 'id', value: 'abc123' },
+ ]
+ expect(getRowId(row)).toBe('abc123')
+ })
+
+ test('falls back to the first cell itemId when there is no id cell', () => {
+ const row = [{ dataKey: 'name', value: 'Foo', itemId: 'xyz789' }]
+ expect(getRowId(row)).toBe('xyz789')
+ })
+})
+
+describe('getRowClickAction', () => {
+ const rows = [
+ [{ dataKey: 'id', value: 'a', itemId: 'a' }],
+ [{ dataKey: 'id', value: 'b', itemId: 'b' }],
+ [{ dataKey: 'id', value: 'c', itemId: 'c' }],
+ [{ dataKey: 'id', value: 'd', itemId: 'd' }],
+ ]
+
+ test('plain click is ignored', () => {
+ expect(
+ getRowClickAction(
+ {},
+ { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null }
+ )
+ ).toBeNull()
+ })
+
+ test('ctrl-click toggles just that row', () => {
+ expect(
+ getRowClickAction(
+ { ctrlKey: true },
+ { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null }
+ )
+ ).toEqual({ type: 'toggle', id: 'b' })
+ })
+
+ test('shift-click with no prior anchor falls back to a single-row toggle', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null }
+ )
+ ).toEqual({ type: 'toggle', id: 'c' })
+ })
+
+ test('shift-click with a prior anchor selects the range between them', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 }
+ )
+ ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] })
+ })
+
+ test('shift-click range works regardless of anchor/target order', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 }
+ )
+ ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] })
+ })
+})
+
+describe('getNextSorting', () => {
+ test('clicking an unsorted column starts at ascending', () => {
+ expect(
+ getNextSorting('name', { sortField: null, sortDirection: 'asc' })
+ ).toEqual({ sortField: 'name', sortDirection: 'asc' })
+ })
+
+ test('clicking the ascending-sorted column moves to descending', () => {
+ expect(
+ getNextSorting('name', { sortField: 'name', sortDirection: 'asc' })
+ ).toEqual({ sortField: 'name', sortDirection: 'desc' })
+ })
+
+ test('clicking the descending-sorted column clears back to natural order', () => {
+ expect(
+ getNextSorting('name', { sortField: 'name', sortDirection: 'desc' })
+ ).toEqual({ sortField: null, sortDirection: 'asc' })
+ })
+
+ test('clicking a different column restarts the cycle at ascending', () => {
+ expect(
+ getNextSorting('type', { sortField: 'name', sortDirection: 'desc' })
+ ).toEqual({ sortField: 'type', sortDirection: 'asc' })
+ })
+})
+
+describe('isFilterable', () => {
+ test('allows numeric and string columns', () => {
+ expect(isFilterable('rawValue', 'number')).toBe(true)
+ expect(isFilterable('name', 'string')).toBe(true)
+ })
+
+ test('excludes columns with no type (no known filter UI for them)', () => {
+ expect(isFilterable('someKey', undefined)).toBe(false)
+ })
+})
diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js
index 5db9075a14..92d9d97642 100644
--- a/src/util/__tests__/favorites.spec.js
+++ b/src/util/__tests__/favorites.spec.js
@@ -921,4 +921,108 @@ describe('cleanMapConfig', () => {
])
expect(cleanedConfig.mapViews[0].config).toBeUndefined()
})
+
+ test('serializes dataTableColumnConfig into config JSON for thematic layer', () => {
+ const dataTableColumnConfig = {
+ visibleKeys: ['name', 'rawValue'],
+ pinnedKeys: ['name'],
+ orderedKeys: ['rawValue', 'name'],
+ }
+ const config = {
+ mapViews: [
+ {
+ layer: 'thematic',
+ name: 'Test',
+ rows: [],
+ dataTableColumnConfig,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.dataTableColumnConfig).toEqual(
+ dataTableColumnConfig
+ )
+ expect(mapView).not.toHaveProperty('dataTableColumnConfig')
+ })
+
+ test('serializes dataTableColumnConfig into config JSON for earth engine layer', () => {
+ const dataTableColumnConfig = { visibleKeys: ['name'] }
+ const config = {
+ mapViews: [
+ {
+ layer: 'earthEngine',
+ layerId: 'MODIS/006/MOD13A2',
+ rows: [],
+ dataTableColumnConfig,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.dataTableColumnConfig).toEqual(
+ dataTableColumnConfig
+ )
+ expect(mapView).not.toHaveProperty('dataTableColumnConfig')
+ })
+
+ test('serializes dataTableColumnConfig into config JSON for TEI layer', () => {
+ const dataTableColumnConfig = { pinnedKeys: ['id'] }
+ const config = {
+ mapViews: [
+ {
+ layer: 'trackedEntity',
+ name: 'Tracked entity',
+ rows: [],
+ dataTableColumnConfig,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.dataTableColumnConfig).toEqual(
+ dataTableColumnConfig
+ )
+ expect(mapView).not.toHaveProperty('dataTableColumnConfig')
+ })
+
+ test('serializes dataTableColumnConfig into config JSON for geojson layer', () => {
+ const dataTableColumnConfig = { orderedKeys: ['name', 'id'] }
+ const config = {
+ mapViews: [
+ {
+ layer: 'geoJsonUrl',
+ name: 'My GeoJSON',
+ rows: [],
+ config: {
+ id: 'abc',
+ url: 'https://example.com/geo.json',
+ },
+ dataTableColumnConfig,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.dataTableColumnConfig).toEqual(
+ dataTableColumnConfig
+ )
+ expect(mapView).not.toHaveProperty('dataTableColumnConfig')
+ })
})
diff --git a/src/util/__tests__/filterInput.spec.js b/src/util/__tests__/filterInput.spec.js
new file mode 100644
index 0000000000..48c894fa6c
--- /dev/null
+++ b/src/util/__tests__/filterInput.spec.js
@@ -0,0 +1,138 @@
+import {
+ getDisplayValue,
+ getFilteredOptions,
+ getPopoverWidth,
+ getSelectedAndAppliedString,
+ measureMaxTextWidth,
+} from '../filterInput.js'
+
+describe('getSelectedAndAppliedString', () => {
+ it('treats an array filterValue as the selected checkboxes', () => {
+ expect(getSelectedAndAppliedString(['a', 'b'])).toEqual({
+ selected: ['a', 'b'],
+ appliedString: '',
+ })
+ })
+
+ it('treats a string filterValue as an applied custom filter', () => {
+ expect(getSelectedAndAppliedString('> 5')).toEqual({
+ selected: [],
+ appliedString: '> 5',
+ })
+ })
+
+ it('returns empty defaults when there is no filterValue yet', () => {
+ expect(getSelectedAndAppliedString(undefined)).toEqual({
+ selected: [],
+ appliedString: '',
+ })
+ })
+})
+
+describe('getDisplayValue', () => {
+ it('shows the live search text while the popover is open, regardless of other state', () => {
+ expect(
+ getDisplayValue({
+ isOpen: true,
+ searchText: 'typing…',
+ selected: ['a'],
+ appliedString: '> 5',
+ })
+ ).toBe('typing…')
+ })
+
+ it('shows a selection count when closed with checkboxes selected', () => {
+ expect(
+ getDisplayValue({
+ isOpen: false,
+ searchText: '',
+ selected: ['a', 'b'],
+ appliedString: '',
+ })
+ ).toBe('2 selected')
+ })
+
+ it('falls back to the applied custom filter string when closed with nothing selected', () => {
+ expect(
+ getDisplayValue({
+ isOpen: false,
+ searchText: '',
+ selected: [],
+ appliedString: '> 5',
+ })
+ ).toBe('> 5')
+ })
+})
+
+describe('getFilteredOptions', () => {
+ const realOptions = [{ value: '3' }, { value: '7' }, { value: '12' }]
+
+ it('returns every option unchanged when there is no search text', () => {
+ expect(
+ getFilteredOptions({
+ realOptions,
+ trimmedSearch: '',
+ normalizedSearch: '',
+ type: 'number',
+ resolveLabel: (v) => v,
+ })
+ ).toBe(realOptions)
+ })
+
+ it('filters numeric columns using the typed filter expression, not substring match', () => {
+ const result = getFilteredOptions({
+ realOptions,
+ trimmedSearch: '> 5',
+ normalizedSearch: '> 5',
+ type: 'number',
+ resolveLabel: (v) => v,
+ })
+ expect(result.map((o) => o.value)).toEqual(['7', '12'])
+ })
+
+ it('filters string columns by case-insensitive substring match on the resolved label', () => {
+ const stringOptions = [{ value: 'a' }, { value: 'b' }, { value: 'c' }]
+ const resolveLabel = (v) =>
+ ({ a: 'Apple', b: 'Banana', c: 'Cherry' }[v])
+ const result = getFilteredOptions({
+ realOptions: stringOptions,
+ trimmedSearch: 'AN',
+ normalizedSearch: 'an',
+ type: 'string',
+ resolveLabel,
+ })
+ expect(result.map((o) => o.value)).toEqual(['b'])
+ })
+})
+
+describe('measureMaxTextWidth', () => {
+ it('returns the width of the longest of several strings', () => {
+ const font = '11px sans-serif'
+ const short = measureMaxTextWidth(['a'], font)
+ const long = measureMaxTextWidth(['a much longer piece of text'], font)
+ const max = measureMaxTextWidth(
+ ['a', 'a much longer piece of text'],
+ font
+ )
+ expect(max).toBe(long)
+ expect(long).toBeGreaterThan(short)
+ })
+
+ it('returns 0 for an empty list of strings', () => {
+ expect(measureMaxTextWidth([], '11px sans-serif')).toBe(0)
+ })
+})
+
+describe('getPopoverWidth', () => {
+ it('clamps up to the minimum width for a small measured label', () => {
+ expect(getPopoverWidth(1)).toBe(140)
+ })
+
+ it('clamps down to the maximum width for a very wide measured label', () => {
+ expect(getPopoverWidth(1000)).toBe(280)
+ })
+
+ it('passes a mid-range measurement through with the non-label width added', () => {
+ expect(getPopoverWidth(100)).toBe(156)
+ })
+})
diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js
new file mode 100644
index 0000000000..77db56a701
--- /dev/null
+++ b/src/util/__tests__/tableColumns.spec.js
@@ -0,0 +1,303 @@
+import {
+ getPinnedCellProps,
+ getPinnedCount,
+ getPinnedLeftOffsets,
+ getVisibleHeaders,
+ isPinnedGroupEnd,
+ reverseVisibleKeys,
+ togglePinnedKey,
+ toggleVisibleKey,
+} from '../tableColumns.js'
+
+const headers = [
+ { name: 'Name', dataKey: 'name' },
+ { name: 'Id', dataKey: 'id' },
+ { name: 'Value', dataKey: 'rawValue' },
+ { name: 'Legend', dataKey: 'legend' },
+]
+
+describe('getVisibleHeaders', () => {
+ it('returns all headers unchanged when there is no saved config', () => {
+ expect(getVisibleHeaders(headers, null)).toEqual(headers)
+ })
+
+ it('passes through a null/undefined headers list', () => {
+ expect(getVisibleHeaders(null, null)).toBe(null)
+ })
+
+ it('treats an explicit null for any config field the same as it being absent', () => {
+ const result = getVisibleHeaders(headers, {
+ visibleKeys: null,
+ orderedKeys: null,
+ pinnedKeys: null,
+ })
+ expect(result).toEqual(headers)
+ })
+
+ it('hides every column when visibleKeys is an explicit empty array', () => {
+ expect(getVisibleHeaders(headers, { visibleKeys: [] })).toEqual([])
+ })
+
+ it('filters out headers not in visibleKeys', () => {
+ const result = getVisibleHeaders(headers, {
+ visibleKeys: ['name', 'legend'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual(['name', 'legend'])
+ })
+
+ it('keeps a header visible when visibleKeys is not set at all', () => {
+ const result = getVisibleHeaders(headers, { pinnedKeys: ['name'] })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'name',
+ 'id',
+ 'rawValue',
+ 'legend',
+ ])
+ })
+
+ it('reorders headers according to orderedKeys', () => {
+ const result = getVisibleHeaders(headers, {
+ orderedKeys: ['legend', 'name', 'id', 'rawValue'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'legend',
+ 'name',
+ 'id',
+ 'rawValue',
+ ])
+ })
+
+ it('appends headers missing from orderedKeys at the end, preserving their relative order', () => {
+ const result = getVisibleHeaders(headers, {
+ orderedKeys: ['rawValue'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'rawValue',
+ 'name',
+ 'id',
+ 'legend',
+ ])
+ })
+
+ it('drops a stale dataKey in orderedKeys/visibleKeys that no longer matches any header', () => {
+ const result = getVisibleHeaders(headers, {
+ orderedKeys: ['deletedColumn', 'legend', 'name', 'id', 'rawValue'],
+ visibleKeys: ['deletedColumn', 'name', 'legend'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual(['legend', 'name'])
+ })
+
+ it('moves pinned columns to the front, regardless of orderedKeys', () => {
+ const result = getVisibleHeaders(headers, {
+ orderedKeys: ['name', 'id', 'rawValue', 'legend'],
+ pinnedKeys: ['rawValue'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'rawValue',
+ 'name',
+ 'id',
+ 'legend',
+ ])
+ })
+
+ it('preserves relative order among multiple pinned columns', () => {
+ const result = getVisibleHeaders(headers, {
+ pinnedKeys: ['legend', 'id'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'id',
+ 'legend',
+ 'name',
+ 'rawValue',
+ ])
+ })
+
+ it('combines ordering, visibility, and pinning together', () => {
+ const result = getVisibleHeaders(headers, {
+ orderedKeys: ['legend', 'name', 'id', 'rawValue'],
+ visibleKeys: ['legend', 'name', 'rawValue'],
+ pinnedKeys: ['rawValue'],
+ })
+ expect(result.map((h) => h.dataKey)).toEqual([
+ 'rawValue',
+ 'legend',
+ 'name',
+ ])
+ })
+})
+
+describe('getPinnedLeftOffsets', () => {
+ const visibleHeaders = [
+ { name: 'Value', dataKey: 'rawValue' },
+ { name: 'Name', dataKey: 'name' },
+ { name: 'Id', dataKey: 'id' },
+ ]
+ const columnWidths = [100, 150, 80]
+
+ it('returns no offsets when there are no pinned keys', () => {
+ expect(getPinnedLeftOffsets(visibleHeaders, [], columnWidths)).toEqual(
+ {}
+ )
+ })
+
+ it('returns no offsets when column widths have not been measured yet', () => {
+ expect(getPinnedLeftOffsets(visibleHeaders, ['rawValue'], [])).toEqual(
+ {}
+ )
+ })
+
+ it('starts the first pinned column after the checkbox column', () => {
+ const offsets = getPinnedLeftOffsets(
+ visibleHeaders,
+ ['rawValue'],
+ columnWidths
+ )
+ expect(offsets).toEqual({ rawValue: 76 })
+ })
+
+ it('accumulates offsets for consecutive pinned columns', () => {
+ const offsets = getPinnedLeftOffsets(
+ visibleHeaders,
+ ['rawValue', 'name'],
+ columnWidths
+ )
+ expect(offsets).toEqual({ rawValue: 76, name: 176 })
+ })
+
+ it('only offsets columns that are actually pinned', () => {
+ const offsets = getPinnedLeftOffsets(
+ visibleHeaders,
+ ['id'],
+ columnWidths
+ )
+ expect(offsets).toEqual({ id: 76 })
+ })
+
+ it('does not let an unpinned column contribute width when pinned columns are not contiguous', () => {
+ // 'name' sits between the two pinned columns here and must not
+ // inflate 'id's offset.
+ const offsets = getPinnedLeftOffsets(
+ visibleHeaders,
+ ['rawValue', 'id'],
+ columnWidths
+ )
+ expect(offsets).toEqual({ rawValue: 76, id: 176 })
+ })
+})
+
+describe('getPinnedCount', () => {
+ it('returns 0 when no headers are pinned', () => {
+ expect(getPinnedCount(headers, [])).toBe(0)
+ })
+
+ it('counts the leading headers that are pinned', () => {
+ expect(getPinnedCount(headers, ['name', 'id'])).toBe(2)
+ })
+
+ it('returns the full length when every header is pinned', () => {
+ const allKeys = headers.map((h) => h.dataKey)
+ expect(getPinnedCount(headers, allKeys)).toBe(headers.length)
+ })
+})
+
+describe('isPinnedGroupEnd', () => {
+ it('is false when nothing is pinned', () => {
+ expect(isPinnedGroupEnd(headers[0], 0, headers)).toBe(false)
+ })
+
+ it('is false when every header is pinned (no unpinned group to separate from)', () => {
+ expect(isPinnedGroupEnd(headers[3], headers.length, headers)).toBe(
+ false
+ )
+ })
+
+ it('is true only for the last pinned header when there is a mix', () => {
+ const pinnedCount = 2
+ expect(isPinnedGroupEnd(headers[0], pinnedCount, headers)).toBe(false)
+ expect(isPinnedGroupEnd(headers[1], pinnedCount, headers)).toBe(true)
+ expect(isPinnedGroupEnd(headers[2], pinnedCount, headers)).toBe(false)
+ })
+})
+
+describe('toggleVisibleKey', () => {
+ it('adds a key when checking it', () => {
+ expect(toggleVisibleKey(['name'], 'id', true)).toEqual(['name', 'id'])
+ })
+
+ it('removes a key when unchecking it', () => {
+ expect(toggleVisibleKey(['name', 'id'], 'name', false)).toEqual(['id'])
+ })
+})
+
+describe('togglePinnedKey', () => {
+ it('adds a key when it is not yet pinned', () => {
+ expect(togglePinnedKey(['name'], 'id')).toEqual(['name', 'id'])
+ })
+
+ it('removes a key when it is already pinned', () => {
+ expect(togglePinnedKey(['name', 'id'], 'name')).toEqual(['id'])
+ })
+})
+
+describe('reverseVisibleKeys', () => {
+ it('returns the dataKeys not currently in visibleKeys', () => {
+ const result = reverseVisibleKeys(headers, ['name', 'legend'])
+ expect(result).toEqual(['id', 'rawValue'])
+ })
+
+ it('returns every dataKey when nothing is currently visible', () => {
+ const result = reverseVisibleKeys(headers, [])
+ expect(result).toEqual(['name', 'id', 'rawValue', 'legend'])
+ })
+})
+
+describe('getPinnedCellProps', () => {
+ const pinnedLeftOffsets = { rawValue: 76, name: 176 }
+ const pinnedColumnCount = 2
+ const columnWidths = [100, 150, 80, 120]
+
+ it('marks a pinned-in-range column as fixed with its left/width offsets', () => {
+ expect(
+ getPinnedCellProps('rawValue', 0, {
+ pinnedLeftOffsets,
+ pinnedColumnCount,
+ columnWidths,
+ })
+ ).toEqual({
+ fixed: true,
+ left: '76px',
+ width: '100px',
+ isLastPinned: false,
+ })
+ })
+
+ it('leaves an unpinned column unfixed with no left/width offsets', () => {
+ expect(
+ getPinnedCellProps('id', 2, {
+ pinnedLeftOffsets,
+ pinnedColumnCount,
+ columnWidths,
+ })
+ ).toEqual({
+ fixed: false,
+ left: undefined,
+ width: undefined,
+ isLastPinned: false,
+ })
+ })
+
+ it('flags isLastPinned only at the final pinned index', () => {
+ expect(
+ getPinnedCellProps('name', 1, {
+ pinnedLeftOffsets,
+ pinnedColumnCount,
+ columnWidths,
+ })
+ ).toEqual({
+ fixed: true,
+ left: '176px',
+ width: '150px',
+ isLastPinned: true,
+ })
+ })
+})
diff --git a/src/util/dataTable.js b/src/util/dataTable.js
new file mode 100644
index 0000000000..3760d7d45f
--- /dev/null
+++ b/src/util/dataTable.js
@@ -0,0 +1,44 @@
+import { SORT_ASCENDING, SORT_DESCENDING } from '../constants/dataTable.js'
+
+export const isFilterable = (dataKey, type) => !!type
+
+export const shouldClearFeatureHighlight = (event) =>
+ event.relatedTarget?.tagName !== 'TD'
+
+export const getNextSorting = (name, { sortField, sortDirection }) => {
+ if (name !== sortField) {
+ return { sortField: name, sortDirection: SORT_ASCENDING }
+ }
+ if (sortDirection === SORT_ASCENDING) {
+ return { sortField: name, sortDirection: SORT_DESCENDING }
+ }
+ return { sortField: null, sortDirection: SORT_ASCENDING }
+}
+
+export const getRowId = (row) =>
+ row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId
+
+export const getRowClickAction = (
+ event,
+ { id, rowIndex, rows, lastClickedRowIndex }
+) => {
+ if (event.shiftKey) {
+ if (lastClickedRowIndex === null) {
+ return { type: 'toggle', id }
+ }
+ const [start, end] = [lastClickedRowIndex, rowIndex].sort(
+ (a, b) => a - b
+ )
+ const ids = rows
+ .slice(start, end + 1)
+ .map(getRowId)
+ .filter(Boolean)
+ return { type: 'range', ids }
+ }
+
+ if (event.ctrlKey || event.metaKey) {
+ return { type: 'toggle', id }
+ }
+
+ return null
+}
diff --git a/src/util/favorites.js b/src/util/favorites.js
index 520c919873..96032a36aa 100644
--- a/src/util/favorites.js
+++ b/src/util/favorites.js
@@ -37,6 +37,7 @@ const validLayerProperties = [
'columns',
'config',
'created',
+ 'dataTableColumnConfig',
'datasetId',
'displayName',
'endDate',
@@ -180,6 +181,9 @@ const buildCommonLayerConfigData = (layer) => {
if (layer.labelDataItem) {
configData.labelDataItem = layer.labelDataItem
}
+ if (layer.dataTableColumnConfig) {
+ configData.dataTableColumnConfig = layer.dataTableColumnConfig
+ }
return configData
}
@@ -194,11 +198,26 @@ const deleteCommonLayerConfigProps = (layer) => {
delete layer.countFeaturesWithoutCoordinates
delete layer.countEventsOutsideOrgUnits
delete layer.labelDataItem
+ delete layer.dataTableColumnConfig
}
const buildEarthEngineLayerConfigData = (layer) => {
- const { layerId: id, band, style, aggregationType, period } = layer
- return omitBy(isNil, { id, style, band, aggregationType, period })
+ const {
+ layerId: id,
+ band,
+ style,
+ aggregationType,
+ period,
+ dataTableColumnConfig,
+ } = layer
+ return omitBy(isNil, {
+ id,
+ style,
+ band,
+ aggregationType,
+ period,
+ dataTableColumnConfig,
+ })
}
const deleteEarthEngineLayerProps = (layer) => {
@@ -211,6 +230,7 @@ const deleteEarthEngineLayerProps = (layer) => {
delete layer.periodType
delete layer.aggregationType
delete layer.band
+ delete layer.dataTableColumnConfig
}
const buildTrackedEntityLayerConfigData = (layer) => ({
@@ -224,6 +244,7 @@ const buildTrackedEntityLayerConfigData = (layer) => ({
}
: null,
periodType: layer.periodType,
+ dataTableColumnConfig: layer.dataTableColumnConfig,
})
const deleteTrackedEntityLayerProps = (layer) => {
@@ -233,6 +254,7 @@ const deleteTrackedEntityLayerProps = (layer) => {
delete layer.relationshipLineColor
delete layer.relationshipOutsideProgram
delete layer.periodType
+ delete layer.dataTableColumnConfig
}
// TODO: This feels hacky, find better way to clean map configs before saving
@@ -268,9 +290,13 @@ const models2objects = (layer, cleanMapviewConfig) => {
layer.config = {
...layer.config,
featureStyle: { ...layer.featureStyle },
+ ...(layer.dataTableColumnConfig !== undefined && {
+ dataTableColumnConfig: layer.dataTableColumnConfig,
+ }),
}
}
delete layer.featureStyle
+ delete layer.dataTableColumnConfig
} else if (
layerType === EVENT_LAYER ||
layerType === THEMATIC_LAYER ||
diff --git a/src/util/filterInput.js b/src/util/filterInput.js
new file mode 100644
index 0000000000..dc86ddd3ec
--- /dev/null
+++ b/src/util/filterInput.js
@@ -0,0 +1,68 @@
+import i18n from '@dhis2/d2-i18n'
+import { numericFilter } from './filter.js'
+
+const POPOVER_ROW_NON_LABEL_WIDTH = 56
+const MIN_POPOVER_WIDTH = 140
+const MAX_POPOVER_WIDTH = 280
+
+export const getSelectedAndAppliedString = (filterValue) => ({
+ selected: Array.isArray(filterValue) ? filterValue : [],
+ appliedString: typeof filterValue === 'string' ? filterValue : '',
+})
+
+export const getDisplayValue = ({
+ isOpen,
+ searchText,
+ selected,
+ appliedString,
+}) => {
+ if (isOpen) {
+ return searchText
+ }
+ if (selected.length) {
+ return i18n.t('{{count}} selected', { count: selected.length })
+ }
+ return appliedString
+}
+
+export const getFilteredOptions = ({
+ realOptions,
+ trimmedSearch,
+ normalizedSearch,
+ type,
+ resolveLabel,
+}) => {
+ if (!trimmedSearch) {
+ return realOptions
+ }
+ if (type === 'number') {
+ return realOptions.filter(({ value }) =>
+ numericFilter(Number(value), trimmedSearch)
+ )
+ }
+ return realOptions.filter(({ value }) =>
+ resolveLabel(value).toLowerCase().includes(normalizedSearch)
+ )
+}
+
+let measureCanvasContext = null
+export const measureMaxTextWidth = (texts, font) => {
+ if (!measureCanvasContext) {
+ measureCanvasContext = document.createElement('canvas').getContext('2d')
+ }
+ measureCanvasContext.font = font
+ return texts.reduce(
+ (max, text) =>
+ Math.max(max, measureCanvasContext.measureText(text).width),
+ 0
+ )
+}
+
+export const getPopoverWidth = (maxLabelWidth) =>
+ Math.min(
+ Math.max(
+ maxLabelWidth + POPOVER_ROW_NON_LABEL_WIDTH,
+ MIN_POPOVER_WIDTH
+ ),
+ MAX_POPOVER_WIDTH
+ )
diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js
new file mode 100644
index 0000000000..b6e86f87c3
--- /dev/null
+++ b/src/util/tableColumns.js
@@ -0,0 +1,105 @@
+const CHECKBOX_COLUMN_WIDTH = 76
+
+const getOrderIndex = (dataKey, orderedKeys) => {
+ const index = orderedKeys.indexOf(dataKey)
+ return index === -1 ? orderedKeys.length : index
+}
+
+export const getVisibleHeaders = (headers, columnConfig) => {
+ if (!headers) {
+ return headers
+ }
+
+ const { visibleKeys, orderedKeys } = columnConfig ?? {}
+ const pinnedKeys = columnConfig?.pinnedKeys ?? []
+
+ let result = orderedKeys
+ ? [...headers].sort(
+ (a, b) =>
+ getOrderIndex(a.dataKey, orderedKeys) -
+ getOrderIndex(b.dataKey, orderedKeys)
+ )
+ : headers
+
+ if (visibleKeys) {
+ result = result.filter((h) => visibleKeys.includes(h.dataKey))
+ }
+
+ if (pinnedKeys.length) {
+ const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey))
+ const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey))
+ result = [...pinned, ...rest]
+ }
+
+ return result
+}
+
+export const getPinnedCount = (orderedHeaders, pinnedKeys) => {
+ if (!orderedHeaders?.length || !pinnedKeys?.length) {
+ return 0
+ }
+ let count = 0
+ for (const header of orderedHeaders) {
+ if (!pinnedKeys.includes(header.dataKey)) {
+ break
+ }
+ count++
+ }
+ return count
+}
+
+export const isPinnedGroupEnd = (header, pinnedCount, orderedHeaders) =>
+ pinnedCount > 0 &&
+ pinnedCount < orderedHeaders.length &&
+ header.dataKey === orderedHeaders[pinnedCount - 1].dataKey
+
+export const toggleVisibleKey = (visibleKeys, dataKey, checked) =>
+ checked
+ ? [...visibleKeys, dataKey]
+ : visibleKeys.filter((k) => k !== dataKey)
+
+export const togglePinnedKey = (pinnedKeys, dataKey) =>
+ pinnedKeys.includes(dataKey)
+ ? pinnedKeys.filter((k) => k !== dataKey)
+ : [...pinnedKeys, dataKey]
+
+export const reverseVisibleKeys = (headers, visibleKeys) =>
+ headers
+ .filter((h) => !visibleKeys.includes(h.dataKey))
+ .map((h) => h.dataKey)
+
+// @dhis2/ui requires `width` whenever `fixed` is passed
+export const getPinnedCellProps = (
+ dataKey,
+ index,
+ { pinnedLeftOffsets, pinnedColumnCount, columnWidths }
+) => {
+ const leftOffset = pinnedLeftOffsets[dataKey]
+ const isPinned = index < pinnedColumnCount && leftOffset !== undefined
+ return {
+ fixed: isPinned,
+ left: isPinned ? `${leftOffset}px` : undefined,
+ width: isPinned ? `${columnWidths[index] ?? 0}px` : undefined,
+ isLastPinned: index === pinnedColumnCount - 1,
+ }
+}
+
+export const getPinnedLeftOffsets = (
+ visibleHeaders,
+ pinnedKeys,
+ columnWidths
+) => {
+ const offsets = {}
+ if (!pinnedKeys?.length || !columnWidths?.length) {
+ return offsets
+ }
+
+ let offset = CHECKBOX_COLUMN_WIDTH
+ visibleHeaders.forEach((header, index) => {
+ if (pinnedKeys.includes(header.dataKey)) {
+ offsets[header.dataKey] = offset
+ offset += columnWidths[index] ?? 0
+ }
+ })
+ return offsets
+}