From 6fa82e8de96a854be1dd704ba72c31f9526b6bc2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 15:05:09 +0200 Subject: [PATCH 01/21] feat: add DATA_TABLE_COLUMN_CONFIG_SET reducer action and persistence plumbing Adds the Redux action/reducer for a per-layer dataTableColumnConfig field and wires it through favorites.js save/load and all layer loaders, mirroring the existing legendDecimalPlaces config-blob pattern. No UI yet. --- src/actions/dataTable.js | 6 ++++++ src/constants/actionTypes.js | 1 + src/loaders/eventLoader.js | 4 ++++ src/loaders/facilityLoader.js | 10 ++++++++-- src/loaders/geoJsonUrlLoader.js | 7 ++++++- src/loaders/orgUnitLoader.js | 10 ++++++++-- src/loaders/thematicLoader.js | 4 ++++ src/loaders/trackedEntityLoader.js | 8 +++++++- src/reducers/map.js | 11 +++++++++++ src/util/favorites.js | 30 ++++++++++++++++++++++++++++-- 10 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 5b9adde54a..ceb7ed56e3 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -32,3 +32,9 @@ export const setHighlightColor = (color) => ({ type: types.HIGHLIGHT_COLOR_SET, color, }) + +export const setDataTableColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COLUMN_CONFIG_SET, + layerId, + config, +}) 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/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 || From c8a0a2ad24f4bc742f555b5b95681860c26db24c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 15:35:05 +0200 Subject: [PATCH 02/21] feat: add pure column visibility/order/pin computation getVisibleHeaders/getPinnedLeftOffsets in a new src/util/tableColumns.js, directly unit-tested, matching the filterSelection.js/tableSort.js pattern. Not yet wired into DataTable.jsx. --- src/util/__tests__/tableColumns.spec.js | 178 ++++++++++++++++++++++++ src/util/tableColumns.js | 71 ++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/util/__tests__/tableColumns.spec.js create mode 100644 src/util/tableColumns.js diff --git a/src/util/__tests__/tableColumns.spec.js b/src/util/__tests__/tableColumns.spec.js new file mode 100644 index 0000000000..6d7eabfa4a --- /dev/null +++ b/src/util/__tests__/tableColumns.spec.js @@ -0,0 +1,178 @@ +import { getPinnedLeftOffsets, getVisibleHeaders } 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', () => { + // Not a realistic input in practice (getVisibleHeaders always + // makes pinned columns contiguous first), but the function itself + // shouldn't silently corrupt offsets if that invariant is broken. + const offsets = getPinnedLeftOffsets( + visibleHeaders, + ['rawValue', 'id'], + columnWidths + ) + expect(offsets).toEqual({ rawValue: 76, id: 176 }) + }) +}) diff --git a/src/util/tableColumns.js b/src/util/tableColumns.js new file mode 100644 index 0000000000..1e3567e619 --- /dev/null +++ b/src/util/tableColumns.js @@ -0,0 +1,71 @@ +const CHECKBOX_COLUMN_WIDTH = 76 + +const getOrderIndex = (dataKey, orderedKeys) => { + const index = orderedKeys.indexOf(dataKey) + return index === -1 ? orderedKeys.length : index +} + +// Computes the headers actually shown, in display order, from the full +// header list and a saved dataTableColumnConfig. Handles headers whose +// dataKey no longer exists (harmlessly dropped, since this always starts +// from the current `headers`) and headers that exist but were never part +// of a saved config (kept visible, ordered last). +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 + + // visibleKeys is only set once a user has actually configured columns - + // before that, columnConfig is null and every header shows. Once set, + // it's the definitive "on" list: a dataKey added later (e.g. a new EE + // band) that was never part of that saved list stays hidden until the + // user explicitly turns it on, rather than reappearing unexpectedly. + if (visibleKeys) { + result = result.filter((h) => visibleKeys.includes(h.dataKey)) + } + + if (pinnedKeys.length) { + // position: sticky only freezes columns that are actually + // contiguous at the start of display order, so pinned columns + // must be moved to the front here, not just flagged for styling. + const pinned = result.filter((h) => pinnedKeys.includes(h.dataKey)) + const rest = result.filter((h) => !pinnedKeys.includes(h.dataKey)) + result = [...pinned, ...rest] + } + + return result +} + +// Left offset (px) for each pinned column's sticky positioning, keyed by +// dataKey. `visibleHeaders`/`columnWidths` must be in the same display +// order (i.e. already passed through getVisibleHeaders). +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 +} From 0244925d8d55ac03728c661aeef0ff1a9e0316d2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 15:58:16 +0200 Subject: [PATCH 03/21] feat: wire column visibility/pin/order into DataTable rendering Computes visibleHeaders from dataTableColumnConfig, feeds it into useColumnWidths, and applies @dhis2/ui's built-in fixed/left sticky-column support for pinned columns. The checkbox column only becomes fixed when something else is pinned, since @dhis2/ui renders fixed cells as instead of - doing this unconditionally would break every existing Cypress td-index assertion. Also fires onHeadersChange with the full unfiltered header list for a future ColumnPicker to consume. CSS selectors that were td-only (dataCell/lightText/monoCell/selected/ hovered) now also match th, since a pinned column's cells render as . --- src/components/datatable/DataTable.jsx | 281 ++++++++++++------ .../datatable/styles/DataTable.module.css | 24 +- 2 files changed, 213 insertions(+), 92 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7e05c4b3f8..8b13fd20bd 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -42,6 +42,10 @@ import { } from '../../constants/selection.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' +import { + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' @@ -307,6 +311,7 @@ const TableComponents = { const Table = ({ availableWidth, onCountChange, + onHeadersChange, globalSearch, onClearFilters, }) => { @@ -425,12 +430,72 @@ const Table = ({ globalSearch, }) + useEffect(() => { + onHeadersChange?.(headers) + }, [onHeadersChange, headers]) + + const columnConfig = layer.dataTableColumnConfig + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig), + [headers, columnConfig] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, - headers, + headers: visibleHeaders, error, }) + // Only the leading columns of visibleHeaders can ever be pinned - + // getVisibleHeaders already moves pinned columns to the front - so the + // pinned section's size is just how many headers match pinnedKeys + // before the first one that doesn't. + const pinnedColumnCount = useMemo(() => { + if (!pinnedKeys.length || !visibleHeaders) { + return 0 + } + let count = 0 + for (const header of visibleHeaders) { + if (!pinnedKeys.includes(header.dataKey)) { + break + } + count++ + } + return count + }, [visibleHeaders, pinnedKeys]) + + const pinnedLeftOffsets = useMemo( + () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), + [visibleHeaders, pinnedKeys, columnWidths] + ) + const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 + + // The checkbox column only becomes sticky when something else is + // actually pinned (and its offset is ready) - otherwise it stays a + // plain (non-`fixed`) cell, since @dhis2/ui renders `fixed` cells as + // `` rather than ``, which would needlessly change the DOM + // shape for the common, nothing-pinned case, and briefly during column + // widths being (re)measured after a config change. + const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady + + // @dhis2/ui requires `width` whenever `fixed` is passed - unpinned + // cells keep their existing (unset) width behavior. + const getPinnedCellProps = (dataKey, index) => { + 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, + } + } + useEffect(() => { onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) @@ -577,6 +642,8 @@ const Table = ({ - {headers.map( - ({ name, dataKey, type, optionSet }, index) => ( - - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - - {name} - - - - - - ) + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + + {name} + + + + + + ) + } )} )} @@ -710,10 +791,21 @@ const Table = ({ feature?.id === rowId && feature?.layerId === layer.id + const cellsByDataKey = new Map( + row.map((cell) => [cell.dataKey, cell]) + ) + return ( <> e.stopPropagation()} /> - {row.map(({ dataKey, value, align }) => ( - - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - - ))} + {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) + return ( + + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + + ) + })} ) }} @@ -797,6 +905,7 @@ Table.propTypes = { globalSearch: PropTypes.string, onClearFilters: PropTypes.func, onCountChange: PropTypes.func, + onHeadersChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 068594371e..b49f201729 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -7,22 +7,28 @@ user-select: none; } -td.dataCell { +/* A pinned column's cells render as (@dhis2/ui's DataTableCell switches + element on `fixed`), so these need to match both td and th. */ +td.dataCell, +th.dataCell { padding-top: var(--spacers-dp8); padding-bottom: var(--spacers-dp8); font-size: 11px; overflow-wrap: anywhere; } -td.dataCell:hover { +td.dataCell:hover, +th.dataCell:hover { cursor: default; } -td.lightText { +td.lightText, +th.lightText { color: var(--colors-white); } -td.monoCell { +td.monoCell, +th.monoCell { font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; } @@ -71,14 +77,20 @@ td.checkboxCell { font-size: 11px !important; } -td.selected { +td.selected, +th.selected { background-color: var(--colors-blue050); } -td.hovered { +td.hovered, +th.hovered { background-color: var(--colors-blue100); } +.pinnedColumnShadow { + box-shadow: 2px 0 4px -2px rgba(12, 14, 16, 0.15); +} + .columnHeader > :global(span.container), .checkboxCell > :global(span.container) { justify-content: space-between; From 6b4f60295d9bffaa90997aca882a9b8468b5e2be Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 16:12:43 +0200 Subject: [PATCH 04/21] feat: add ColumnPicker component for show/hide, pin, and reorder New popover, built on the existing FilterDropdownPopover shell and the same @dnd-kit drag-reorder pattern already used in LayersPanel.jsx (not the raw HTML5 drag-and-drop the original design sketch used). Dispatches setDataTableColumnConfig directly; not yet wired into BottomPanel. --- src/components/datatable/ColumnPicker.jsx | 297 ++++++++++++++++++ .../datatable/styles/ColumnPicker.module.css | 118 +++++++ 2 files changed, 415 insertions(+) create mode 100644 src/components/datatable/ColumnPicker.jsx create mode 100644 src/components/datatable/styles/ColumnPicker.module.css diff --git a/src/components/datatable/ColumnPicker.jsx b/src/components/datatable/ColumnPicker.jsx new file mode 100644 index 0000000000..1e2c9e9ee6 --- /dev/null +++ b/src/components/datatable/ColumnPicker.jsx @@ -0,0 +1,297 @@ +import i18n from '@dhis2/d2-i18n' +import { + IconDragHandle16, + IconLayoutColumns16, + IconLock16, + IconLockOpen16, +} from '@dhis2/ui' +import { + DndContext, + DragOverlay, + closestCenter, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { arrayMoveImmutable } from 'array-move' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { setDataTableColumnConfig } from '../../actions/dataTable.js' +import { getVisibleHeaders } from '../../util/tableColumns.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import styles from './styles/ColumnPicker.module.css' + +const ColumnRow = ({ + header, + isVisible, + isPinned, + onToggleVisible, + onTogglePinned, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.dataKey }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0 : 1, + } + + const pinLabel = isPinned + ? i18n.t('Unpin column') + : i18n.t('Pin column to the left') + + return ( +
+ + onToggleVisible(header.dataKey, checked)} + className={styles.columnRowCheckbox} + dataTest={`data-table-column-picker-visible-${header.dataKey}`} + /> + +
+ ) +} + +ColumnRow.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + onTogglePinned: PropTypes.func.isRequired, + onToggleVisible: PropTypes.func.isRequired, +} + +const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { + const dispatch = useDispatch() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [activeId, setActiveId] = useState(null) + + // useTableData can legitimately return a null headers list (e.g. while + // loading or on error) - guard here rather than trust callers to. + const headers = allHeaders ?? [] + + const visibleKeys = + columnConfig?.visibleKeys ?? headers.map((h) => h.dataKey) + const pinnedKeys = columnConfig?.pinnedKeys ?? [] + const orderedKeys = + columnConfig?.orderedKeys ?? headers.map((h) => h.dataKey) + + // Same reorder-then-pin-to-front logic the table itself renders with, + // so the picker's row order always matches the table's actual column + // order. visibleKeys is deliberately not passed here - every column + // gets a row in the picker (hidden ones just show an unchecked box). + // Dragging a column across the pinned/unpinned boundary still snaps it + // back to whichever side its own pinned state puts it on next render - + // pin state is the button's job, not drag's. + const orderedHeaders = getVisibleHeaders(headers, { + orderedKeys, + pinnedKeys, + }) + + const updateConfig = (partial) => + dispatch( + setDataTableColumnConfig(layerId, { + visibleKeys, + pinnedKeys, + orderedKeys, + ...partial, + }) + ) + + const onToggleVisible = (dataKey, checked) => { + const next = checked + ? [...visibleKeys, dataKey] + : visibleKeys.filter((k) => k !== dataKey) + updateConfig({ visibleKeys: next }) + } + + const onTogglePinned = (dataKey) => { + const next = pinnedKeys.includes(dataKey) + ? pinnedKeys.filter((k) => k !== dataKey) + : [...pinnedKeys, dataKey] + updateConfig({ pinnedKeys: next }) + } + + const sensors = useSensors( + useSensor(MouseSensor, { + // Require a small movement so a click on the handle isn't a drag + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const onDragEnd = ({ active, over }) => { + setActiveId(null) + + if (over && active.id !== over.id) { + const oldIndex = orderedHeaders.findIndex( + (h) => h.dataKey === active.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) + updateConfig({ orderedKeys: nextOrder }) + } + } + } + + const activeHeader = orderedHeaders.find((h) => h.dataKey === activeId) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const { dropdownPlacement } = getDropdownPlacement(anchorRect) + + return ( + <> + + {isOpen && ( + setIsOpen(false)} + > +
+

+ {i18n.t( + 'Drag to reorder, check to show or hide, lock to pin left' + )} +

+ setActiveId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setActiveId(null)} + > + h.dataKey)} + strategy={verticalListSortingStrategy} + > +
+ {orderedHeaders.map((header) => ( + + ))} +
+
+ + {activeHeader ? ( +
+ + {activeHeader.name} +
+ ) : null} +
+
+
+
+ )} + + ) +} + +ColumnPicker.propTypes = { + layerId: PropTypes.string.isRequired, + allHeaders: PropTypes.arrayOf( + PropTypes.shape({ + dataKey: PropTypes.string, + name: PropTypes.string, + }) + ), + columnConfig: PropTypes.shape({ + orderedKeys: PropTypes.arrayOf(PropTypes.string), + pinnedKeys: PropTypes.arrayOf(PropTypes.string), + visibleKeys: PropTypes.arrayOf(PropTypes.string), + }), +} + +export default ColumnPicker diff --git a/src/components/datatable/styles/ColumnPicker.module.css b/src/components/datatable/styles/ColumnPicker.module.css new file mode 100644 index 0000000000..0e0abf6e68 --- /dev/null +++ b/src/components/datatable/styles/ColumnPicker.module.css @@ -0,0 +1,118 @@ +.triggerButton { + cursor: pointer; + color: var(--colors-grey800); + background-color: transparent; + width: 24px; + height: 24px; + border: none; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; +} + +.triggerButton:hover:not(:disabled) { + color: var(--colors-grey900); + background-color: var(--colors-grey300); +} + +.triggerButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.columnPickerPopover { + padding: var(--spacers-dp8); + min-width: 220px; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} + +.columnPickerHint { + margin: 0 0 var(--spacers-dp8); + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} + +.columnList { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; +} + +.columnRow { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: 2px 0; +} + +.columnRowCheckbox { + flex: 1; + min-width: 0; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--colors-grey600); + cursor: grab; + touch-action: none; +} + +.dragHandle:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.pinButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--colors-grey500); + cursor: pointer; +} + +.pinButton:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.pinButtonActive { + color: var(--colors-blue700); +} + +.pinButtonActive:hover { + color: var(--colors-blue800); +} + +.dragOverlay { + display: flex; + align-items: center; + gap: var(--spacers-dp4); + padding: 2px var(--spacers-dp8); + background-color: var(--colors-white); + border-radius: 3px; + box-shadow: var(--elevations-popover); + font-size: 12px; +} From 89cd9027662f92f94d638e253f8f0ab0f597c0a6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 16 Jul 2026 16:32:10 +0200 Subject: [PATCH 05/21] test: add coverage for dataTableColumnConfig persistence and ColumnPicker favorites.spec.js: end-to-end cleanMapConfig round-trip for dataTableColumnConfig across thematic/earthEngine/TEI/geojson layer types, verified to actually depend on the validLayerProperties whitelist entry. ColumnPicker.spec.jsx: render-and-interact tests (disabled state, default visibility, toggle/pin dispatch with full config assertions, pinned-first ordering). Drag-reorder (onDragEnd) isn't covered - confirmed by direct experiment that dnd-kit's KeyboardSensor doesn't produce a usable drag in jsdom without much heavier mocking than is worthwhile here. --- .../datatable/__tests__/ColumnPicker.spec.jsx | 151 ++++++++++++++++++ src/util/__tests__/favorites.spec.js | 104 ++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/components/datatable/__tests__/ColumnPicker.spec.jsx diff --git a/src/components/datatable/__tests__/ColumnPicker.spec.jsx b/src/components/datatable/__tests__/ColumnPicker.spec.jsx new file mode 100644 index 0000000000..f1dd502a10 --- /dev/null +++ b/src/components/datatable/__tests__/ColumnPicker.spec.jsx @@ -0,0 +1,151 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' +import ColumnPicker from '../ColumnPicker.jsx' + +const mockStore = configureMockStore() + +const headers = [ + { name: 'Name', dataKey: 'name' }, + { name: 'Value', dataKey: 'rawValue' }, + { name: 'Legend', dataKey: 'legend' }, +] + +const renderColumnPicker = (props) => { + const store = mockStore({}) + const result = render( + + + + ) + return { ...result, store } +} + +const openPicker = () => + fireEvent.click(screen.getByTestId('data-table-column-picker-button')) + +describe('ColumnPicker trigger', () => { + test('is disabled when there are no headers yet', () => { + renderColumnPicker({ allHeaders: null }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is disabled when allHeaders is an empty array', () => { + renderColumnPicker({ allHeaders: [] }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is enabled once headers are available', () => { + renderColumnPicker() + expect( + screen.getByTestId('data-table-column-picker-button') + ).not.toBeDisabled() + }) + + test('a click on the disabled trigger does not open the popover', () => { + renderColumnPicker({ allHeaders: [] }) + openPicker() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + }) + + test('opens a popover listing every column, checked by default', () => { + renderColumnPicker() + openPicker() + expect(screen.getByLabelText('Name')).toBeChecked() + expect(screen.getByLabelText('Value')).toBeChecked() + expect(screen.getByLabelText('Legend')).toBeChecked() + }) +}) + +describe('ColumnPicker visibility toggling', () => { + test('unchecking a column dispatches visibleKeys without that column, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('rechecking a hidden column dispatches visibleKeys with it added back, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name', 'legend'] }, + }) + openPicker() + expect(screen.getByLabelText('Value')).not.toBeChecked() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend', 'rawValue'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) +}) + +describe('ColumnPicker pinning', () => { + test('pinning a column dispatches pinnedKeys including it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['rawValue'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('unpinning an already-pinned column dispatches pinnedKeys without it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { pinnedKeys: ['rawValue'] }, + }) + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('renders pinned columns first, ahead of orderedKeys', () => { + renderColumnPicker({ + columnConfig: { + orderedKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['legend'], + }, + }) + openPicker() + const labels = screen + .getAllByRole('checkbox') + .map((el) => el.closest('label')?.textContent) + expect(labels).toEqual(['Legend', 'Name', 'Value']) + }) +}) 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') + }) }) From 3d18945d6dfd0888ea3632a661c8f48725c9809e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 17 Jul 2026 00:05:07 +0200 Subject: [PATCH 06/21] fix: small improvements --- src/components/core/Checkbox.jsx | 2 +- src/components/core/icons.jsx | 4 +- src/components/datatable/BottomPanel.jsx | 30 ++- src/components/datatable/ColumnPicker.jsx | 232 ++++++++++++++---- src/components/datatable/FilterInput.jsx | 61 ++++- .../datatable/styles/ColumnPicker.module.css | 68 +++-- .../datatable/styles/DataTable.module.css | 22 +- 7 files changed, 330 insertions(+), 89 deletions(-) diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 18a7798a40..5567af53e2 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -43,7 +43,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, - label: PropTypes.string, + label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, } diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b46a6354f5..1a067ea7bd 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -13,7 +13,7 @@ export const SortIcon = ({ direction }) => ( ( { const [isCollapsed, setIsCollapsed] = useState(false) const [searchInputValue, setSearchInputValue] = useState('') const globalSearch = useDebouncedValue(searchInputValue, 200) + const [headersByLayer, setHeadersByLayer] = useState(null) const hasActiveFilters = Object.keys(dataFilters).length > 0 || @@ -85,6 +87,16 @@ const BottomPanel = () => { [] ) + const onControlsDoubleClick = useCallback( + (e) => { + if (e.target.closest('button, input, label')) { + return + } + toggleCollapsed() + }, + [toggleCollapsed] + ) + const onResizeStart = useCallback(() => { isDraggingRef.current = true }, []) @@ -115,6 +127,15 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onHeadersChange = useCallback((headers, layerId) => { + setHeadersByLayer({ layerId, headers }) + }, []) + + const allHeaders = + headersByLayer?.layerId === activeLayerId + ? headersByLayer.headers + : null + const onClearFilters = useCallback(() => { dispatch(clearDataFilters(activeLayerId)) dispatch(setSelectionFilter([])) @@ -196,7 +217,7 @@ const BottomPanel = () => { >
{header.name} + } checked={isVisible} onChange={(checked) => onToggleVisible(header.dataKey, checked)} className={styles.columnRowCheckbox} - dataTest={`data-table-column-picker-visible-${header.dataKey}`} + dataTest={`data-table-column-picker-visible-${header.dataKey}${dataTestSuffix}`} /> + + ) +} + +ColumnRowFields.propTypes = { + header: PropTypes.shape({ + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + isVisible: PropTypes.bool.isRequired, + dataTestSuffix: PropTypes.string, + dragHandleProps: PropTypes.object, + suppressTooltips: PropTypes.bool, + onTogglePinned: PropTypes.func, + onToggleVisible: PropTypes.func, +} + +const ColumnRow = ({ + header, + isVisible, + isPinned, + isPinnedGroupEnd, + isDragActive, + onToggleVisible, + onTogglePinned, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: header.dataKey }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + opacity: isDragging ? 0 : 1, + } + + return ( +
+
) } @@ -105,7 +190,9 @@ ColumnRow.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, }).isRequired, + isDragActive: PropTypes.bool.isRequired, isPinned: PropTypes.bool.isRequired, + isPinnedGroupEnd: PropTypes.bool.isRequired, isVisible: PropTypes.bool.isRequired, onTogglePinned: PropTypes.func.isRequired, onToggleVisible: PropTypes.func.isRequired, @@ -139,6 +226,17 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { pinnedKeys, }) + // Mirrors DataTable.jsx's pinnedColumnCount: getVisibleHeaders already + // moves pinned columns to the front, so the pinned group's size is just + // how many headers match pinnedKeys before the first one that doesn't. + let pinnedCount = 0 + for (const header of orderedHeaders) { + if (!pinnedKeys.includes(header.dataKey)) { + break + } + pinnedCount++ + } + const updateConfig = (partial) => dispatch( setDataTableColumnConfig(layerId, { @@ -210,12 +308,15 @@ const ColumnPicker = ({ layerId, allHeaders, columnConfig }) => { ref={anchorRef} className={styles.triggerButton} disabled={!headers.length} - title={i18n.t('Configure columns')} aria-label={i18n.t('Configure columns')} data-test="data-table-column-picker-button" onClick={() => setIsOpen((o) => !o)} > - + + + + + {isOpen && ( { onClickOutside={() => setIsOpen(false)} >
-

- {i18n.t( - 'Drag to reorder, check to show or hide, lock to pin left' - )} -

{ strategy={verticalListSortingStrategy} >
- {orderedHeaders.map((header) => ( + {orderedHeaders.map((header, index) => ( { isPinned={pinnedKeys.includes( header.dataKey )} + isPinnedGroupEnd={ + index === pinnedCount - 1 && + pinnedCount < + orderedHeaders.length + } + isDragActive={activeId != null} onToggleVisible={onToggleVisible} onTogglePinned={onTogglePinned} /> ))}
- - {activeHeader ? ( -
- - {activeHeader.name} -
- ) : null} -
+ {createPortal( + // DragOverlay renders inline wherever it's + // placed and relies on `position: fixed` to + // escape into the viewport - but it's nested + // inside FilterDropdownPopover's Popper, + // which positions itself via a CSS + // `transform`. A `transform` on an ancestor + // creates a new containing block for + // `position: fixed` descendants, so without + // this portal the overlay ends up positioned + // relative to the popover instead of the + // viewport (rendering off-screen or hidden). + + {activeHeader ? ( +
+ +
+ ) : null} +
, + document.body + )}
diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index ee2fd45156..65f0d0b604 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, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -30,6 +30,11 @@ 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 MIN_POPOVER_WIDTH = 140 +const MAX_POPOVER_WIDTH = 280 +// Checkbox icon + its margin, popover padding (both sides) and the +// scrollbar .multiSelectPopover's overflow-y: auto can show - none of +// which is part of the label text itself. +const POPOVER_ROW_CHROME_WIDTH = 56 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -50,6 +55,24 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g +// Options render through react-virtuoso, which positions rows absolutely +// for virtualization - out-of-flow content like that is excluded from CSS's +// own intrinsic (max-content) sizing, so a container can never grow to fit +// virtualized content via CSS alone. Measuring the label text directly is +// the standard workaround. +let measureCanvasContext = null +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 + ) +} + const helpTooltipModifiers = [ { name: 'offset', options: { offset: [0, 4] } }, { name: 'flip', enabled: false }, @@ -196,7 +219,6 @@ const SearchableFilterPopover = ({ const closePopover = () => setIsOpen(false) const anchorRect = anchorRef.current?.getBoundingClientRect() - const anchorWidth = anchorRect?.width const { dropdownPlacement, dropdownSide, tooltipPlacement } = getDropdownPlacement(anchorRect) @@ -226,6 +248,23 @@ const SearchableFilterPopover = ({ const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const popoverWidth = useMemo(() => { + const labels = realOptions.map((o) => resolveLabel(o.value)) + if (hasNotSetOption) { + labels.push(resolveLabel(SENTINEL_NO_VALUE)) + } + const font = `11px ${getComputedStyle(document.body).fontFamily}` + const maxLabelWidth = measureMaxTextWidth(labels, font) + return Math.min( + Math.max( + maxLabelWidth + POPOVER_ROW_CHROME_WIDTH, + MIN_POPOVER_WIDTH + ), + MAX_POPOVER_WIDTH + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [realOptions, hasNotSetOption]) + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) @@ -406,14 +445,7 @@ const SearchableFilterPopover = ({ className={cx(styles.searchableFilterPopover, { [styles.reversedOrder]: dropdownSide === 'top', })} - style={{ - minWidth: anchorWidth - ? `${Math.max( - anchorWidth, - MIN_POPOVER_WIDTH - )}px` - : undefined, - }} + style={{ width: `${popoverWidth}px` }} > {showCustomFilterRow && ( - ) -} +const IconButton = forwardRef( + ( + { + tooltip, + onClick, + className, + children, + dataTest, + disabled, + ariaLabel, + }, + ref + ) => { + return ( + + ) + } +) + +IconButton.displayName = 'IconButton' IconButton.propTypes = { ariaLabel: PropTypes.string, diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index bd2abb3f5a..6cc11b4861 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,14 +1,3 @@ -import i18n from '@dhis2/d2-i18n' -import { - IconCross16, - IconFilter16, - IconEmptyFrame16, - IconChevronDown16, - IconChevronUp16, - Input, - Tooltip, -} from '@dhis2/ui' -import cx from 'classnames' import React, { useRef, useCallback, @@ -17,7 +6,6 @@ import React, { useEffect, useLayoutEffect, } from 'react' -import { createPortal } from 'react-dom' import { useSelector, useDispatch } from 'react-redux' import { clearDataFilters } from '../../actions/dataFilters.js' import { @@ -30,12 +18,19 @@ import { import useDebouncedValue from '../../hooks/useDebouncedValue.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' -import ColorPicker from '../core/ColorPicker.jsx' import { useWindowDimensions } from '../WindowDimensionsProvider.jsx' -import ColumnPicker from './ColumnPicker.jsx' +import ActiveLayerControl from './controls/ActiveLayerControl.jsx' +import ClearFiltersControl from './controls/ClearFiltersControl.jsx' +import CloseControl from './controls/CloseControl.jsx' +import CollapseControl from './controls/CollapseControl.jsx' +import ColumnPickerControl from './controls/ColumnPickerControl.jsx' +import GlobalSearchControl from './controls/GlobalSearchControl.jsx' +import HighlightColorControl from './controls/HighlightColorControl.jsx' +import ResizeHandleControl from './controls/ResizeHandleControl.jsx' +import RowCountControl from './controls/RowCountControl.jsx' +import ShowInViewControl from './controls/ShowInViewControl.jsx' import DataTable from './DataTable.jsx' import ErrorBoundary from './ErrorBoundary.jsx' -import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' // Must match `.dataTableControls`'s height in BottomPanel.module.css @@ -59,12 +54,10 @@ const BottomPanel = () => { const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) - const nameRef = useRef(null) const isDraggingRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) - const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) const [searchInputValue, setSearchInputValue] = useState('') const globalSearch = useDebouncedValue(searchInputValue, 200) @@ -145,25 +138,18 @@ const BottomPanel = () => { } }, [dispatch, activeLayerId, showOnlyFeaturesInView]) - const onNameMouseEnter = useCallback(() => { - const el = nameRef.current - if (!el || el.scrollWidth <= el.offsetWidth) { - return - } - const rect = el.getBoundingClientRect() - const computed = getComputedStyle(el) - const lineHeight = Number.parseFloat(computed.lineHeight) - setNameTooltipPos({ - top: rect.top + (rect.height - lineHeight) / 2, - left: rect.left, - color: computed.color, - fontSize: computed.fontSize, - lineHeight: `${lineHeight}px`, - paddingLeft: computed.paddingLeft, - }) - }, []) + const onToggleShowOnlyFeaturesInView = useCallback(() => { + dispatch(toggleShowOnlyFeaturesInView()) + }, [dispatch]) - const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + const onCloseDataTable = useCallback(() => { + dispatch(closeDataTable()) + }, [dispatch]) + + const onHighlightColorChange = useCallback( + (color) => dispatch(setHighlightColor(color)), + [dispatch] + ) useLayoutEffect(() => { if (isDraggingRef.current) { @@ -195,19 +181,7 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - useKeyDown('Escape', () => dispatch(closeDataTable()), true) - - const rowCountLabel = useMemo(() => { - if (totalCount === null || filteredCount === null) { - return null - } - return filteredCount < totalCount - ? i18n.t('{{filtered}} of {{total}} rows', { - filtered: filteredCount, - total: totalCount, - }) - : i18n.t('{{total}} rows', { total: totalCount }) - }, [totalCount, filteredCount]) + useKeyDown('Escape', onCloseDataTable, true) return (
{ type="button" className={styles.toggleButton} onClick={toggleCollapsed} - > - - {isCollapsed ? ( - - ) : ( - - )} - - + /> - - {activeLayer?.name} - - {nameTooltipPos && - createPortal( -
- {activeLayer?.name} -
, - document.body - )} + - - - - dispatch(setHighlightColor(color)) - } - /> - - - + - { const store = mockStore({}) const result = render( - + ) return { ...result, store } diff --git a/src/components/datatable/controls/ActiveLayerControl.jsx b/src/components/datatable/controls/ActiveLayerControl.jsx new file mode 100644 index 0000000000..0d076955c5 --- /dev/null +++ b/src/components/datatable/controls/ActiveLayerControl.jsx @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types' +import React, { useCallback, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import styles from './styles/ActiveLayerControl.module.css' + +// Must match .nameTooltip's top/bottom padding in ActiveLayerControl.module.css - +// offsets the tooltip's top so that extra padding grows the background +// without shifting the text's own vertical position. +const TOOLTIP_VERTICAL_PADDING = 3 + +const ActiveLayerControl = ({ name }) => { + const nameRef = useRef(null) + const [nameTooltipPos, setNameTooltipPos] = useState(null) + + const onMouseEnter = useCallback(() => { + const el = nameRef.current + if (!el || el.scrollWidth <= el.offsetWidth) { + return + } + const rect = el.getBoundingClientRect() + const computed = getComputedStyle(el) + const lineHeight = Number.parseFloat(computed.lineHeight) + setNameTooltipPos({ + top: + rect.top + + (rect.height - lineHeight) / 2 - + TOOLTIP_VERTICAL_PADDING, + left: rect.left, + color: computed.color, + fontSize: computed.fontSize, + fontWeight: computed.fontWeight, + lineHeight: `${lineHeight}px`, + paddingLeft: computed.paddingLeft, + }) + }, []) + + const onMouseLeave = useCallback(() => setNameTooltipPos(null), []) + + return ( + <> + + {name} + + {nameTooltipPos && + createPortal( +
+ {name} +
, + document.body + )} + + ) +} + +ActiveLayerControl.propTypes = { + name: PropTypes.string, +} + +export default ActiveLayerControl diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx new file mode 100644 index 0000000000..902e0fc7ed --- /dev/null +++ b/src/components/datatable/controls/ClearFiltersControl.jsx @@ -0,0 +1,26 @@ +import i18n from '@dhis2/d2-i18n' +import { IconFilter16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/ClearFiltersControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const ClearFiltersControl = ({ disabled, onClick }) => ( + + + + + + +) + +ClearFiltersControl.propTypes = { + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool, +} + +export default ClearFiltersControl diff --git a/src/components/datatable/controls/CloseControl.jsx b/src/components/datatable/controls/CloseControl.jsx new file mode 100644 index 0000000000..1240bcc442 --- /dev/null +++ b/src/components/datatable/controls/CloseControl.jsx @@ -0,0 +1,17 @@ +import i18n from '@dhis2/d2-i18n' +import { IconCross16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const CloseControl = ({ onClick }) => ( + + + +) + +CloseControl.propTypes = { + onClick: PropTypes.func.isRequired, +} + +export default CloseControl diff --git a/src/components/datatable/controls/CollapseControl.jsx b/src/components/datatable/controls/CollapseControl.jsx new file mode 100644 index 0000000000..1f7f896c01 --- /dev/null +++ b/src/components/datatable/controls/CollapseControl.jsx @@ -0,0 +1,21 @@ +import i18n from '@dhis2/d2-i18n' +import { IconChevronDown16, IconChevronUp16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const CollapseControl = ({ isCollapsed, onClick }) => ( + + {isCollapsed ? : } + +) + +CollapseControl.propTypes = { + isCollapsed: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +} + +export default CollapseControl diff --git a/src/components/datatable/ColumnPicker.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx similarity index 94% rename from src/components/datatable/ColumnPicker.jsx rename to src/components/datatable/controls/ColumnPickerControl.jsx index 4eea47177b..3a44f8619a 100644 --- a/src/components/datatable/ColumnPicker.jsx +++ b/src/components/datatable/controls/ColumnPickerControl.jsx @@ -30,14 +30,15 @@ import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useDispatch } from 'react-redux' -import { setDataTableColumnConfig } from '../../actions/dataTable.js' -import { getVisibleHeaders } from '../../util/tableColumns.js' -import Checkbox from '../core/Checkbox.jsx' +import { setDataTableColumnConfig } from '../../../actions/dataTable.js' +import { getVisibleHeaders } from '../../../util/tableColumns.js' +import Checkbox from '../../core/Checkbox.jsx' import { FilterDropdownPopover, getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import styles from './styles/ColumnPicker.module.css' +} from '../FilterDropdownPopover.jsx' +import styles from './styles/ColumnPickerControl.module.css' +import ToolbarIconButton from './ToolbarIconButton.jsx' // Higher than this codebase's usual z-index: 2000 "float above everything" // convention (e.g. DataTable.module.css's .topTooltipContent), since the @@ -81,7 +82,7 @@ const ColumnRowFields = ({ <> + + {isOpen && ( { ) } -ColumnPicker.propTypes = { +ColumnPickerControl.propTypes = { layerId: PropTypes.string.isRequired, allHeaders: PropTypes.arrayOf( PropTypes.shape({ @@ -422,4 +418,4 @@ ColumnPicker.propTypes = { }), } -export default ColumnPicker +export default ColumnPickerControl diff --git a/src/components/datatable/controls/GlobalSearchControl.jsx b/src/components/datatable/controls/GlobalSearchControl.jsx new file mode 100644 index 0000000000..1547538f11 --- /dev/null +++ b/src/components/datatable/controls/GlobalSearchControl.jsx @@ -0,0 +1,32 @@ +import i18n from '@dhis2/d2-i18n' +import { Input, Tooltip } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/GlobalSearchControl.module.css' + +const GlobalSearchControl = ({ value, onChange }) => ( + +
e.stopPropagation()} + > + onChange(value)} + /> +
+
+) + +GlobalSearchControl.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +} + +export default GlobalSearchControl diff --git a/src/components/datatable/controls/HighlightColorControl.jsx b/src/components/datatable/controls/HighlightColorControl.jsx new file mode 100644 index 0000000000..9534c95ba8 --- /dev/null +++ b/src/components/datatable/controls/HighlightColorControl.jsx @@ -0,0 +1,28 @@ +import i18n from '@dhis2/d2-i18n' +import { Tooltip } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ColorPicker from '../../core/ColorPicker.jsx' +import styles from './styles/HighlightColorControl.module.css' + +const HighlightColorControl = ({ color, onChange }) => ( + + + + + +) + +HighlightColorControl.propTypes = { + onChange: PropTypes.func.isRequired, + color: PropTypes.string, +} + +export default HighlightColorControl diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/controls/ResizeHandleControl.jsx similarity index 90% rename from src/components/datatable/ResizeHandle.jsx rename to src/components/datatable/controls/ResizeHandleControl.jsx index 50711045f2..8ec306ba78 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/controls/ResizeHandleControl.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types' import React, { useEffect, useRef } from 'react' -import { IconDrag } from '../core/icons.jsx' -import styles from './styles/ResizeHandle.module.css' +import { IconDrag } from '../../core/icons.jsx' +import styles from './styles/ResizeHandleControl.module.css' -const ResizeHandle = ({ +const ResizeHandleControl = ({ onResize, onResizeStart, onResizeEnd, @@ -73,7 +73,7 @@ const ResizeHandle = ({ ) } -ResizeHandle.propTypes = { +ResizeHandleControl.propTypes = { maxHeight: PropTypes.number.isRequired, minHeight: PropTypes.number, onResize: PropTypes.func, @@ -81,4 +81,4 @@ ResizeHandle.propTypes = { onResizeStart: PropTypes.func, } -export default ResizeHandle +export default ResizeHandleControl diff --git a/src/components/datatable/controls/RowCountControl.jsx b/src/components/datatable/controls/RowCountControl.jsx new file mode 100644 index 0000000000..274ab4fd1d --- /dev/null +++ b/src/components/datatable/controls/RowCountControl.jsx @@ -0,0 +1,27 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/RowCountControl.module.css' + +const RowCountControl = ({ totalCount, filteredCount }) => { + if (totalCount === null || filteredCount === null) { + return null + } + + const label = + filteredCount < totalCount + ? i18n.t('{{filtered}} of {{total}} rows', { + filtered: filteredCount, + total: totalCount, + }) + : i18n.t('{{total}} rows', { total: totalCount }) + + return {label} +} + +RowCountControl.propTypes = { + filteredCount: PropTypes.number, + totalCount: PropTypes.number, +} + +export default RowCountControl diff --git a/src/components/datatable/controls/ShowInViewControl.jsx b/src/components/datatable/controls/ShowInViewControl.jsx new file mode 100644 index 0000000000..a1297b0ae7 --- /dev/null +++ b/src/components/datatable/controls/ShowInViewControl.jsx @@ -0,0 +1,22 @@ +import i18n from '@dhis2/d2-i18n' +import { IconEmptyFrame16 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import ToolbarIconButton from './ToolbarIconButton.jsx' + +const ShowInViewControl = ({ active, onClick }) => ( + + + +) + +ShowInViewControl.propTypes = { + onClick: PropTypes.func.isRequired, + active: PropTypes.bool, +} + +export default ShowInViewControl diff --git a/src/components/datatable/controls/ToolbarIconButton.jsx b/src/components/datatable/controls/ToolbarIconButton.jsx new file mode 100644 index 0000000000..0c58ee11d6 --- /dev/null +++ b/src/components/datatable/controls/ToolbarIconButton.jsx @@ -0,0 +1,40 @@ +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { forwardRef } from 'react' +import IconButton from '../../core/IconButton.jsx' +import styles from './styles/ToolbarIconButton.module.css' + +const ToolbarIconButton = forwardRef( + ( + { tooltip, onClick, children, dataTest, disabled, ariaLabel, active }, + ref + ) => ( + + {children} + + ) +) + +ToolbarIconButton.displayName = 'ToolbarIconButton' + +ToolbarIconButton.propTypes = { + active: PropTypes.bool, + ariaLabel: PropTypes.string, + children: PropTypes.node, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + tooltip: PropTypes.string, + onClick: PropTypes.func, +} + +export default ToolbarIconButton diff --git a/src/components/datatable/controls/styles/ActiveLayerControl.module.css b/src/components/datatable/controls/styles/ActiveLayerControl.module.css new file mode 100644 index 0000000000..d292736485 --- /dev/null +++ b/src/components/datatable/controls/styles/ActiveLayerControl.module.css @@ -0,0 +1,33 @@ +.layerName { + font-weight: 500; + font-size: 12px; + color: var(--colors-grey800); + flex: 0 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +@keyframes tooltipExpandRight { + from { + clip-path: inset(0 100% 0 0); + } + + to { + clip-path: inset(0 0% 0 0); + } +} + +.nameTooltip { + animation: tooltipExpandRight 160ms ease-out; + background: var(--colors-grey100); + border-radius: 3px; + -webkit-mask-image: linear-gradient(to left, transparent, black 2em); + mask-image: linear-gradient(to left, transparent, black 2em); + padding: 3px 2em 3px 0; + pointer-events: none; + position: fixed; + white-space: nowrap; + z-index: 2000; +} diff --git a/src/components/datatable/controls/styles/ClearFiltersControl.module.css b/src/components/datatable/controls/styles/ClearFiltersControl.module.css new file mode 100644 index 0000000000..6742e21179 --- /dev/null +++ b/src/components/datatable/controls/styles/ClearFiltersControl.module.css @@ -0,0 +1,40 @@ +.filteredIcon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.clearBadge { + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + background: var(--colors-grey100); +} + +:global(button):hover .clearBadge { + background: var(--colors-grey300); +} + +.clearBadge::before, +.clearBadge::after { + content: ''; + position: absolute; + width: 5px; + height: 1px; + background: currentColor; + top: 50%; + left: 50%; +} + +.clearBadge::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.clearBadge::after { + transform: translate(-50%, -50%) rotate(-45deg); +} diff --git a/src/components/datatable/styles/ColumnPicker.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css similarity index 64% rename from src/components/datatable/styles/ColumnPicker.module.css rename to src/components/datatable/controls/styles/ColumnPickerControl.module.css index b1306cff6e..06075f9e19 100644 --- a/src/components/datatable/styles/ColumnPicker.module.css +++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css @@ -1,43 +1,3 @@ -.alignIcon1 { - display: flex; - margin-top: 1px; -} - -.alignIcon1 svg { - width: 18px; - height: 18px; -} - -.alignIcon2 { - display: flex; - margin-top: 2px; -} - -.triggerButton { - cursor: pointer; - color: var(--colors-grey800); - background-color: transparent; - width: 24px; - height: 24px; - border: none; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; -} - -.triggerButton:hover:not(:disabled) { - color: var(--colors-grey900); - background-color: var(--colors-grey300); -} - -.triggerButton:disabled { - color: var(--colors-grey400); - cursor: not-allowed; -} - .columnPickerPopover { padding: var(--spacers-dp8); min-width: 190px; @@ -57,7 +17,7 @@ display: flex; align-items: center; gap: var(--spacers-dp4); - padding: 2px var(--spacers-dp4); + padding: var(--spacers-dp2) var(--spacers-dp4); border-radius: 3px; } @@ -91,7 +51,7 @@ font-size: 12px; } -.dragHandle { +.rowIconButton { display: flex; align-items: center; justify-content: center; @@ -102,6 +62,14 @@ border: none; border-radius: 3px; background: transparent; +} + +.rowIconButton:hover { + background: var(--colors-grey100); + color: var(--colors-grey800); +} + +.dragHandle { /* Prevent the browser's own native drag (e.g. dragging the inline svg icon as an image) from hijacking dnd-kit's mouse sensor. */ -webkit-user-drag: none; @@ -111,31 +79,11 @@ touch-action: none; } -.dragHandle:hover { - background: var(--colors-grey100); - color: var(--colors-grey800); -} - .pinButton { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - padding: 0; - border: none; - border-radius: 3px; - background: transparent; color: var(--colors-grey700); cursor: pointer; } -.pinButton:hover { - background: var(--colors-grey100); - color: var(--colors-grey800); -} - .pinButtonActive { color: var(--colors-teal600); } diff --git a/src/components/datatable/controls/styles/GlobalSearchControl.module.css b/src/components/datatable/controls/styles/GlobalSearchControl.module.css new file mode 100644 index 0000000000..620d4db1fa --- /dev/null +++ b/src/components/datatable/controls/styles/GlobalSearchControl.module.css @@ -0,0 +1,13 @@ +.globalSearch { + flex: 0 1 160px; + min-width: 90px; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} diff --git a/src/components/datatable/controls/styles/HighlightColorControl.module.css b/src/components/datatable/controls/styles/HighlightColorControl.module.css new file mode 100644 index 0000000000..e6c938caea --- /dev/null +++ b/src/components/datatable/controls/styles/HighlightColorControl.module.css @@ -0,0 +1,31 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 3px; +} + +.wrapper:hover { + background-color: var(--colors-grey300); +} + +/* !important beats @dhis2/ui's own ColorPicker field margin. */ +.colorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; +} + +/* !important beats @dhis2/ui's own ColorPicker label size, matching the + other toolbar controls' 16px icon size exactly (same top edge, so + Tooltip's placement="top" lines up with theirs too). It still sits + inside the same 24px hover box as the other controls (.wrapper above). */ +.colorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 16px !important; + min-height: 16px !important; +} diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/controls/styles/ResizeHandleControl.module.css similarity index 100% rename from src/components/datatable/styles/ResizeHandle.module.css rename to src/components/datatable/controls/styles/ResizeHandleControl.module.css diff --git a/src/components/datatable/controls/styles/RowCountControl.module.css b/src/components/datatable/controls/styles/RowCountControl.module.css new file mode 100644 index 0000000000..55e8f60b24 --- /dev/null +++ b/src/components/datatable/controls/styles/RowCountControl.module.css @@ -0,0 +1,6 @@ +.rowCount { + font-size: 11px; + color: var(--colors-grey600); + white-space: nowrap; + flex-shrink: 0; +} diff --git a/src/components/datatable/controls/styles/ToolbarIconButton.module.css b/src/components/datatable/controls/styles/ToolbarIconButton.module.css new file mode 100644 index 0000000000..9b1a43bbf8 --- /dev/null +++ b/src/components/datatable/controls/styles/ToolbarIconButton.module.css @@ -0,0 +1,57 @@ +/* !important beats core/IconButton's own 28px/grey700/grey200-hover defaults, + to keep BottomPanel's established 24px/grey800/grey300-hover look. */ +.toolbarIconButton { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + border-radius: 3px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + flex-shrink: 0; +} + +.toolbarIconButton svg { + color: var(--colors-grey800) !important; +} + +/* @dhis2/ui's Tooltip wraps its child in its own sized by + line-height (text baseline layout), which sits a 16px icon a couple of + pixels above true center. Re-flexing that wrapper overrides the + baseline layout so the icon centers in the 24px button regardless of + whether the icon is passed directly or through a control's own + wrapper span (e.g. ClearFiltersControl's badge wrapper). */ +.toolbarIconButton > span { + display: flex !important; + align-items: center; + justify-content: center; + line-height: 0; +} + +.toolbarIconButton:not(:disabled):hover { + background-color: var(--colors-grey300) !important; +} + +.toolbarIconButton:not(:disabled):hover svg { + color: var(--colors-grey900) !important; +} + +.toolbarIconButton:disabled { + cursor: not-allowed; +} + +.toolbarIconButton:disabled svg { + color: var(--colors-grey400) !important; +} + +.active { + background-color: var(--colors-blue100) !important; +} + +.active svg { + color: var(--colors-blue700) !important; +} + +.active:hover { + background-color: var(--colors-blue200) !important; +} diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 3ef7841083..6468801870 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -29,176 +29,9 @@ border-bottom: 1px solid var(--colors-grey300); } -.layerName { - font-weight: 500; - font-size: 12px; - color: var(--colors-grey800); - flex: 0 1 auto; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - min-width: 0; -} - -.rowCount { - font-size: 11px; - color: var(--colors-grey600); - white-space: nowrap; - flex-shrink: 0; -} - .divider { width: 1px; height: 20px; background-color: var(--colors-grey300); flex-shrink: 0; } - -@keyframes tooltipExpandRight { - from { - clip-path: inset(0 100% 0 0); - } - - to { - clip-path: inset(0 0% 0 0); - } -} - -.nameTooltip { - animation: tooltipExpandRight 160ms ease-out; - background: var(--colors-white); - border-radius: 3px; - -webkit-mask-image: linear-gradient(to left, transparent, black 2em); - mask-image: linear-gradient(to left, transparent, black 2em); - padding: 0 2em 0 0; - pointer-events: none; - position: fixed; - white-space: nowrap; - z-index: 1000; -} - -.filteredIcon { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; -} - -.clearBadge { - position: absolute; - bottom: 0; - right: 0; - width: 8px; - height: 8px; - background: var(--colors-grey100); -} - -.clearFiltersButton:hover .clearBadge { - background: var(--colors-grey300); -} - -.clearBadge::before, -.clearBadge::after { - content: ''; - position: absolute; - width: 5px; - height: 1px; - background: currentColor; - top: 50%; - left: 50%; -} - -.clearBadge::before { - transform: translate(-50%, -50%) rotate(45deg); -} - -.clearBadge::after { - transform: translate(-50%, -50%) rotate(-45deg); -} - -.clearFiltersButton, -.closeIcon, -.toggleButton { - cursor: pointer; - color: var(--colors-grey800); - background-color: transparent; - width: 24px; - height: 24px; - border: none; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; -} - -.alignIcon1 { - display: flex; - margin-top: 1px; -} - -.alignIcon2 { - display: flex; - margin-top: 2px; -} - -.clearFiltersButton:hover, -.closeIcon:hover, -.toggleButton:hover { - color: var(--colors-grey900); - background-color: var(--colors-grey300); -} - -.clearFiltersButton:disabled { - color: var(--colors-grey400); - cursor: not-allowed; -} - -.clearFiltersButton:disabled:hover { - color: var(--colors-grey400); - background-color: transparent; -} - -.toggleButton.active { - color: var(--colors-blue700); - background-color: var(--colors-blue100); -} - -.toggleButton.active:hover { - background-color: var(--colors-blue200); -} - -/* !important beats @dhis2/ui's own ColorPicker field margin. */ -.highlightColorPicker { - margin-bottom: 0 !important; - flex-shrink: 0; - display: flex; - align-items: center; - position: relative; - top: -1px; -} - -/* !important beats @dhis2/ui's own ColorPicker label size. */ -.highlightColorPicker label { - box-sizing: border-box; - overflow: hidden; - min-width: 18px !important; - min-height: 18px !important; -} - -.globalSearch { - flex: 0 1 160px; - min-width: 90px; -} - -.globalSearch > :global(div) { - width: 100%; -} - -.globalSearch :global(input.dense) { - padding: 4px 6px; - font-size: 11px; -} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 4dc3d2d3a8..a24abe4350 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -7,12 +7,6 @@ user-select: none; } -/* A pinned column's cells render as (@dhis2/ui's DataTableCell switches - element on `fixed`), so these need to match both td and th. table-data-cell - sets no vertical-align on (browser default: middle), but @dhis2/ui's - own th styles explicitly set `vertical-align: top` - without overriding it - here, a pinned column's cells sit top-aligned while the rest of the row - stays middle-aligned. */ td.dataCell, th.dataCell { padding-top: var(--spacers-dp8); From d9a194e2c54ce4b956fd83ab66c3330e2c691b27 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 17 Jul 2026 16:53:42 +0200 Subject: [PATCH 08/21] fix: column picker cleanup --- .../__tests__/ColumnPickerControl.spec.jsx | 209 ++++++++++++++++++ .../controls/ColumnPickerControl.jsx | 142 ++++++++++-- .../styles/ColumnPickerControl.module.css | 55 +++++ 3 files changed, 390 insertions(+), 16 deletions(-) diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx index f0292e500c..7fa2ce6fde 100644 --- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -150,6 +150,215 @@ describe('ColumnPicker pinning', () => { const labels = screen .getAllByRole('checkbox') .map((el) => el.closest('label')?.textContent) + // Excludes the bulk "select all" checkbox, which isn't + // wrapped in a