diff --git a/cypress.config.js b/cypress.config.js index 43a62ced8..0717507a8 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -67,7 +67,7 @@ module.exports = defineConfig({ // Enabled to reduce the risk of out-of-memory issues experimentalMemoryManagement: true, // Set to a low number to reduce the risk of out-of-memory issues - numTestsKeptInMemory: 5, + numTestsKeptInMemory: 4, /* When allowing 1 retry on CI, the test suite will pass if * it's flaky. And/but we also get to identify flaky tests on the * Cypress Dashboard. */ diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index f3a0fc47f..6f7db422c 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -3,6 +3,7 @@ import { Layer } from './layer.js' export class EventLayer extends Layer { selectProgram(program) { cy.get('[data-test="programselect"]').click() + cy.contains(program).scrollIntoView() cy.contains(program).click() return this @@ -22,4 +23,11 @@ export class EventLayer extends Layer { return this } + + selectPeriodType(periodType) { + cy.getByDataTest('relative-period-select-content').click() + cy.contains(periodType).click() + + return this + } } diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index ecfff1d48..7f5717f49 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -1,4 +1,15 @@ -import { EXTENDED_TIMEOUT } from '../support/util.js' +import { EventLayer } from '../elements/event_layer.js' +import { CURRENT_YEAR, EXTENDED_TIMEOUT } from '../support/util.js' + +Cypress.on('uncaught:exception', (err) => { + if ( + err.message.includes( + 'ResizeObserver loop completed with undelivered notifications.' + ) + ) { + return false + } +}) const map = { id: 'eDlFx0jTtV9', @@ -8,7 +19,7 @@ const map = { } describe('data table', () => { - it('opens data table', () => { + it('opens data table and filters and sorts', () => { cy.visit(`/#/${map.id}`, EXTENDED_TIMEOUT) cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') @@ -29,19 +40,226 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') - .find('[role="columnheader"]') + .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) - // try the filtering + // Filter by name + cy.getByDataTest('data-table-column-filter-input-Name') + .find('input') + .type('bar') + + // check that the filter returned the correct number of rows + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 7) + + // confirm that the sort order is initially ascending by Name + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .first() + .find('td') + .eq(1) + .should('contain', 'Bargbe') + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .last() + .find('td') + .eq(1) + .should('contain', 'Upper Bambara') + + // Sort by name + cy.get('button[title="Sort by Name"]').click() + + // confirm that the rows are sorted by Name descending + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .first() + .find('td') + .eq(1) + .should('contain', 'Upper Bambara') + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .last() + .find('td') + .eq(1) + .should('contain', 'Bargbe') + + // filter by Value (numeric) + cy.getByDataTest('data-table-column-filter-input-Value') + .find('input') + .type('>26') + + // check that the (combined) filter returned the correct number of rows + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 5) + + // Sort by value + cy.get('button[title="Sort by Value"]').click() + + // check that the rows are sorted by Value ascending + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .first() + .find('td') + .eq(3) + .should('contain', '35') + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .last() + .find('td') + .eq(3) + .should('contain', '76') + + // click on a row + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .first() + .click() + + // check that the org unit profile drawer is opened + cy.getByDataTest('org-unit-profile').should('be.visible') + }) + + it('opens the data table for an Event layer', () => { + cy.visit('/', EXTENDED_TIMEOUT) + + const Layer = new EventLayer() + + Layer.openDialog('Events') + .selectProgram('Malaria case registration') + .validateStage('Malaria case registration') + .selectTab('Period') + .selectPeriodType('Start/end dates') + .typeStartDate(`${CURRENT_YEAR - 1}-01-01`) + .typeEndDate(`${CURRENT_YEAR - 1}-01-15`) + .selectTab('Org Units') + .selectOu('Bo') + .addToMap() + + Layer.validateDialogClosed(true) + + Layer.validateCardTitle('Malaria case registration') + + cy.getByDataTest('moremenubutton').first().click() + + cy.getByDataTest('more-menu') + .find('li') + .contains('Show data table') + .not('disabled') + .click() + + cy.getByDataTest('bottom-panel').should('be.visible') + + // check number of columns + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-datatablecellhead') + .should('have.length', 9) + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-datatablecellhead') + .contains('Age in years', { matchCase: false }) + .should('be.visible') + + // filter by Org unit + const ouName = 'Benduma' + cy.getByDataTest('data-table-column-filter-input-Org unit') + .find('input') + .type(ouName) + + // check that all the rows have Org unit Yakaji + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .first() + .find('td') + .eq(1) + .should('contain', ouName) + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .last() + .find('td') + .eq(1) + .should('contain', ouName) + cy.getByDataTest('bottom-panel') - .find('[role="columnheader"]') - .containsExact('Name') - .siblings('input') - .type('Kakua') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 5) + + // filter by Gender + cy.getByDataTest('data-table-column-filter-input-Gender') + .find('input') + .type('Female') + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 4) + + cy.getByDataTest('data-table-column-filter-input-Gender') + .find('input') + .clear() - // check that the filter worked cy.getByDataTest('bottom-panel') - .find('.ReactVirtualized__Table__row') - .should('have.length', 1) + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 5) + + // filter by Age in years (numeric) + cy.getByDataTest('data-table-column-filter-input-Age in years') + .find('input') + .type('<51') + + // check that the filter returned the correct number of rows + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .should('have.length', 3) + + // Sort by Age in years + cy.get('button[title="Sort by Age in years"]').click() + + // confirm that the rows are sorted by Age in years descending + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .first() + .find('td') + .eq(7) + .should('contain', '44') + + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .find('tr') + .last() + .find('td') + .eq(7) + .should('contain', '6') + + // click on a row + cy.getByDataTest('bottom-panel') + .findByDataTest('dhis2-uicore-tablebody') + .findByDataTest('dhis2-uicore-datatablerow') + .first() + .click() + + // check that the org unit profile drawer is NOT opened + cy.getByDataTest('org-unit-profile').should('not.exist') }) }) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 50b4f5a19..1243879df 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -2,7 +2,7 @@ import '@dhis2/cypress-commands' import 'cypress-wait-until' Cypress.Commands.add('getByDataTest', (selector, ...args) => - cy.get(`[data-test=${selector}]`, ...args) + cy.get(`[data-test="${selector}"]`, ...args) ) Cypress.Commands.add( 'findByDataTest', diff --git a/i18n/en.pot b/i18n/en.pot index 4ae87b4de..71c7fd25c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-01-11T09:20:59.829Z\n" -"PO-Revision-Date: 2024-01-11T09:20:59.829Z\n" +"POT-Creation-Date: 2024-02-12T09:30:18.362Z\n" +"PO-Revision-Date: 2024-02-12T09:30:18.362Z\n" msgid "Untitled map, {{date}}" msgstr "Untitled map, {{date}}" @@ -131,23 +131,23 @@ msgstr "Date" msgid "Data set" msgstr "Data set" -msgid "Index" -msgstr "Index" +msgid "No results found" +msgstr "No results found" -msgid "Org unit" -msgstr "Org unit" +msgid "Sort by {{column}}" +msgstr "Sort by {{column}}" -msgid "Id" -msgstr "Id" +msgid "Something went wrong" +msgstr "Something went wrong" -msgid "Event time" -msgstr "Event time" +msgid "Search" +msgstr "Search" -msgid "Legend" -msgstr "Legend" +msgid "Index" +msgstr "Index" -msgid "Range" -msgstr "Range" +msgid "Id" +msgstr "Id" msgid "Level" msgstr "Level" @@ -158,9 +158,27 @@ msgstr "Parent" msgid "Type" msgstr "Type" +msgid "Legend" +msgstr "Legend" + +msgid "Range" +msgstr "Range" + +msgid "Org unit" +msgstr "Org unit" + +msgid "Event time" +msgstr "Event time" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." +msgid "No valid data was found for the current layer configuration." +msgstr "No valid data was found for the current layer configuration." + +msgid "Data table is not supported for this layer type." +msgstr "Data table is not supported for this layer type." + msgid "Items" msgstr "Items" diff --git a/package.json b/package.json index 0678986a0..438188f05 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@krakenjs/post-robot": "^11.0.0", "@reportportal/agent-js-cypress": "git+https://github.com/dhis2/agent-js-cypress.git#develop", "@reportportal/agent-js-jest": "^5.0.7", + "@testing-library/react-hooks": "^8.0.1", "abortcontroller-polyfill": "^1.7.5", "array-move": "^4.0.0", "classnames": "^2.3.2", @@ -75,6 +76,7 @@ "react-redux": "^8.1.2", "react-sortable-hoc": "^1.11.0", "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.6.2", "redux": "^4.2.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.4.2", diff --git a/src/components/datatable/BottomPanel.js b/src/components/datatable/BottomPanel.js index 62160bcbf..31b2be27c 100644 --- a/src/components/datatable/BottomPanel.js +++ b/src/components/datatable/BottomPanel.js @@ -8,8 +8,9 @@ import { LAYERS_PANEL_WIDTH, RIGHT_PANEL_WIDTH, } from '../../constants/layout.js' -import DataTable from '../datatable/DataTable.js' import { useWindowDimensions } from '../WindowDimensionsProvider.js' +import DataTable from './DataTable.js' +import ErrorBoundary from './ErrorBoundary.js' import ResizeHandle from './ResizeHandle.js' import styles from './styles/BottomPanel.module.css' @@ -39,21 +40,28 @@ const BottomPanel = () => {
- dispatch(closeDataTable())} - > - - - dispatch(resizeDataTable(height))} - /> - +
+ dispatch(resizeDataTable(height))} + /> + +
+ + +
) } diff --git a/src/components/datatable/DataTable.js b/src/components/datatable/DataTable.js index 27c8f7a99..cf661531a 100644 --- a/src/components/datatable/DataTable.js +++ b/src/components/datatable/DataTable.js @@ -1,384 +1,296 @@ import i18n from '@dhis2/d2-i18n' -import { CenteredContent, CircularLoader } from '@dhis2/ui' -import { isValidUid } from 'd2/uid' -import { debounce } from 'lodash/fp' +import { + DataTable, + DataTableRow, + DataTableCell, + DataTableColumnHeader, + DataTableHead, + DataTableBody, + ComponentCover, + CenteredContent, + CircularLoader, +} from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' -import React, { Component } from 'react' -import { connect } from 'react-redux' -import { Table, Column } from 'react-virtualized' -import { closeDataTable } from '../../actions/dataTable.js' +import React, { + useReducer, + useCallback, + useMemo, + useEffect, + useRef, + useState, +} from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' -import { updateLayer } from '../../actions/layers.js' import { setOrgUnitProfile } from '../../actions/orgUnits.js' -import { - EVENT_LAYER, - THEMATIC_LAYER, - ORG_UNIT_LAYER, - EARTH_ENGINE_LAYER, -} from '../../constants/layers.js' -import { numberValueTypes } from '../../constants/valueTypes.js' -import { filterData } from '../../util/filter.js' -import { formatTime } from '../../util/helpers.js' -import ColorCell from './ColorCell.js' -import ColumnHeader from './ColumnHeader.js' -import EarthEngineColumns from './EarthEngineColumns.js' +import { EVENT_LAYER } from '../../constants/layers.js' +import { isDarkColor } from '../../util/colors.js' +import FilterInput from './FilterInput.js' import styles from './styles/DataTable.module.css' -import 'react-virtualized/styles.css' - -// Using react component to keep sorting state, which is only used within the data table. -class DataTable extends Component { - static propTypes = { - closeDataTable: PropTypes.func.isRequired, - height: PropTypes.number.isRequired, - highlightFeature: PropTypes.func.isRequired, - layer: PropTypes.object.isRequired, - setOrgUnitProfile: PropTypes.func.isRequired, - updateLayer: PropTypes.func.isRequired, - width: PropTypes.number.isRequired, - aggregations: PropTypes.object, - feature: PropTypes.object, - } - - static defaultProps = { - data: [], - } - - state = {} - - constructor(props, context) { - super(props, context) - - // Default sort - const sortBy = 'index' - const sortDirection = 'ASC' - - const data = this.sort(this.filter(), sortBy, sortDirection) - - this.state = { - sortBy, - sortDirection, - data, - } - } - - componentDidMount() { - this.loadExtendedData() - } +import { useTableData } from './useTableData.js' + +const ASCENDING = 'asc' +const DESCENDING = 'desc' + +const DataTableWithVirtuosoContext = ({ context, ...props }) => ( + +) + +DataTableWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + layout: PropTypes.string, + }), +} - componentDidUpdate(prevProps) { - const { layer, aggregations, closeDataTable } = this.props - const { data, dataFilters } = layer - const prev = prevProps.layer +const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( + context.onClick(item)} + onMouseEnter={() => context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + {...props} + /> +) + +DataTableRowWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + onClick: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + }), + item: PropTypes.arrayOf( + PropTypes.shape({ + dataKey: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + }) + ), +} - if (!data) { - closeDataTable() - } else if ( - data !== prev.data || - dataFilters !== prev.dataFilters || - aggregations !== prevProps.aggregations - ) { - const { sortBy, sortDirection } = this.state +const TableComponents = { + Table: DataTableWithVirtuosoContext, + TableBody: DataTableBody, + TableHead: DataTableHead, + TableRow: DataTableRowWithVirtuosoContext, + EmptyPlaceholder: () => ( + + +
+ {i18n.t('No results found')} +
+ + + ), +} - this.setState({ - data: this.sort(this.filter(), sortBy, sortDirection), - }) +const Table = ({ availableHeight, availableWidth }) => { + const headerRowRef = useRef(null) + const [columnWidths, setColumnWidths] = useState([]) + const { mapViews } = useSelector((state) => state.map) + const activeLayerId = useSelector((state) => state.dataTable) + + const dispatch = useDispatch() + const feature = useSelector((state) => state.feature) + const [{ sortField, sortDirection }, setSorting] = useReducer( + (sorting, newSorting) => ({ ...sorting, ...newSorting }), + { + sortField: 'name', + sortDirection: ASCENDING, } - } + ) - loadExtendedData() { - const { layer, updateLayer } = this.props - const { layer: layerType, isExtended, serverCluster } = layer + const layer = mapViews.find((l) => l.id === activeLayerId) - if (layerType === EVENT_LAYER && !isExtended && !serverCluster) { - updateLayer({ - ...layer, - showDataTable: true, + const sortData = useCallback( + ({ name }) => { + setSorting({ + sortField: name, + sortDirection: + sortDirection === ASCENDING ? DESCENDING : ASCENDING, }) - } - } - - filter() { - const { layer, aggregations = {} } = this.props - const { dataFilters } = layer - const data = layer.data.filter( - (d) => !d.properties.hasAdditionalGeometry - ) - - return filterData( - data.map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - index, - })), - dataFilters - ) - } - - // TODO: Make sure sorting works across different locales - use lib method - sort(data, sortBy, sortDirection) { - return data.sort((a, b) => { - a = a[sortBy] - b = b[sortBy] - - if (typeof a === 'number') { - return sortDirection === 'ASC' ? a - b : b - a + }, + [sortDirection] + ) + + const showOrgUnitProfile = useCallback( + (row) => { + const id = row.find((r) => r.dataKey === 'id')?.value + id && dispatch(setOrgUnitProfile(id)) + }, + [dispatch] + ) + + const setFeatureHighlight = useCallback( + (row) => { + const id = row.find((r) => r.dataKey === 'id')?.value + if (!id || !feature || id !== feature.id) { + dispatch( + highlightFeature( + id + ? { + id, + layerId: layer.id, + origin: 'table', + } + : null + ) + ) } - - if (a !== undefined) { - return sortDirection === 'ASC' - ? a.localeCompare(b) - : b.localeCompare(a) + }, + [feature, dispatch, layer.id] + ) + const clearFeatureHighlight = useCallback( + (event) => { + const nextElement = event.toElement ?? event.relatedTarget + // When hovering to the next row the next element is a `TD` + // If this is the case `setFeatureHighlight` will + // fire and the highlight does not need to be cleared + if (nextElement.tagName !== 'TD') { + dispatch(highlightFeature(null)) } - - return 0 - }) - } - - onSort(sortBy, sortDirection) { - const { data } = this.state - - this.setState({ - sortBy, - sortDirection, - data: this.sort(data, sortBy, sortDirection), - }) - } - - // Return event data items used for styling, filters or "display in reports" - getEventDataItems() { - const { headers = [] } = this.props.layer - - return headers - .filter(({ name }) => isValidUid(name)) - .map(({ name, column, valueType }) => ({ - key: name, - label: column, - type: numberValueTypes.includes(valueType) - ? 'number' - : 'string', - })) - } - - // Debounce needed as event is triggered multiple times for the same row - highlightFeature = debounce(50, (id) => { - const { feature, layer } = this.props - - // If not the same feature as already highlighted - if (!id || !feature || id !== feature.id) { - this.props.highlightFeature( - id - ? { - id, - layerId: layer.id, - origin: 'table', - } - : null - ) - } + }, + [dispatch] + ) + + const tableContext = useMemo( + () => ({ + onClick: + layer.layer !== EVENT_LAYER + ? showOrgUnitProfile + : Function.prototype, + onMouseEnter: setFeatureHighlight, + onMouseLeave: clearFeatureHighlight, + layout: columnWidths.length > 0 ? 'fixed' : 'auto', + }), + [ + layer.layer, + showOrgUnitProfile, + setFeatureHighlight, + clearFeatureHighlight, + columnWidths, + ] + ) + + const { headers, rows, isLoading, error } = useTableData({ + layer, + sortField, + sortDirection, }) - onRowClick = (evt) => this.props.setOrgUnitProfile(evt.rowData.id) - onRowMouseOver = (evt) => this.highlightFeature(evt.rowData.id) - onRowMouseOut = () => this.highlightFeature() - - render() { - const { data, sortBy, sortDirection } = this.state - const { width, height, layer, aggregations } = this.props - - const { - layer: layerType, - styleDataItem, - serverCluster, - aggregationType, - legend, - } = layer - - const isThematic = layerType === THEMATIC_LAYER - const isOrgUnit = layerType === ORG_UNIT_LAYER - const isEvent = layerType === EVENT_LAYER - const isEarthEngine = layerType === EARTH_ENGINE_LAYER - const isLoading = - isEarthEngine && aggregationType?.length && !aggregations + useEffect(() => { + /* The combination of automtic table layout and virtual scrolling + * causes a content shift when scrolling and filtering because the + * cells in the DOM have a different content length which causes the + * columns to have a different width. To avoid that we measure the + * initial column widths and switch to a fixed layout based on these + * measured widths */ + if (columnWidths.length === 0) { + requestAnimationFrame(() => { + const measuredColumnWidths = [] + + for (const cell of headerRowRef.current.cells) { + const rect = cell.getBoundingClientRect() + measuredColumnWidths.push(Math.floor(rect.width)) + } + + setColumnWidths(measuredColumnWidths) + }) + } + }, [columnWidths]) + + useEffect(() => { + /* When the window is resized, the sidebar opens, or the table + * headers change, the table needs to switch back to its + * automatic layout so that the cells can subsequently can be + * measured again in the useEffect hook above */ + setColumnWidths([]) + }, [availableWidth, headers]) + + if (error) { + return
{error}
+ } - return !serverCluster ? ( - <> - data[index]} - sort={({ sortBy, sortDirection }) => - this.onSort(sortBy, sortDirection) - } - sortBy={sortBy} - sortDirection={sortDirection} - useDynamicRowHeight={false} - hideIndexRow={false} - onRowClick={!isEvent ? this.onRowClick : undefined} - onRowMouseOver={this.onRowMouseOver} - onRowMouseOut={this.onRowMouseOut} - > - rowData.index} - dataKey="index" - label={i18n.t('Index')} - width={72} - className="right" - /> - ( - - )} - /> - ( - - )} - /> - {isEvent && ( - ( - - )} - cellRenderer={({ cellData }) => - cellData ? formatTime(cellData) : '' - } - /> - )} - {isEvent && - this.getEventDataItems().map(({ key, label, type }) => ( - ( - - )} - /> + return ( + <> + ( + + {headers.map(({ name, dataKey, type }, index) => ( + + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + {name} + ))} - {isThematic && ( - ( - - )} - /> - )} - {isThematic && ( - ( - - )} - /> - )} - {isThematic && ( - ( - - )} - /> - )} - {(isThematic || isOrgUnit) && ( - ( - - )} - /> - )} - {(isThematic || isOrgUnit) && ( - ( - - )} - /> - )} - ( - - )} - /> - {(isThematic || styleDataItem) && ( - ( - - )} - cellRenderer={ColorCell} - /> - )} - - {isEarthEngine && - EarthEngineColumns({ aggregationType, legend, data })} -
- {isLoading === true && ( -
- - - -
- )} - - ) : ( -
- {i18n.t( - 'Data table is not supported when events are grouped on the server.' + )} -
- ) - } + itemContent={(_, row) => + row.map(({ dataKey, value, align }) => ( + + {dataKey === 'color' ? value?.toLowerCase() : value} + + )) + } + /> + {isLoading && ( + + + + + + )} + + ) } -export default connect( - ({ dataTable, map, aggregations = {}, feature }) => { - const layer = map.mapViews.find((l) => l.id === dataTable) +Table.propTypes = { + availableHeight: PropTypes.number, + availableWidth: PropTypes.number, +} - return layer - ? { - layer, - feature, - aggregations: aggregations[layer.id], - } - : {} - }, - { - closeDataTable, - updateLayer, - setOrgUnitProfile, - highlightFeature, - } -)(DataTable) +export default Table diff --git a/src/components/datatable/ErrorBoundary.js b/src/components/datatable/ErrorBoundary.js new file mode 100644 index 000000000..d9032eab8 --- /dev/null +++ b/src/components/datatable/ErrorBoundary.js @@ -0,0 +1,40 @@ +import i18n from '@dhis2/d2-i18n' +import { CenteredContent } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { Component } from 'react' + +class ErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { + error: null, + errorInfo: null, + } + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error, + errorInfo, + }) + } + + render() { + const { children } = this.props + if (this.state.error) { + return ( + +

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

+
+ ) + } + + return children + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, +} + +export default ErrorBoundary diff --git a/src/components/datatable/FilterInput.js b/src/components/datatable/FilterInput.js index 109745bc0..16bab4a9c 100644 --- a/src/components/datatable/FilterInput.js +++ b/src/components/datatable/FilterInput.js @@ -1,68 +1,47 @@ +import i18n from '@dhis2/d2-i18n' +import { Input } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { connect } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' -import styles from './styles/FilterInput.module.css' -// http://adazzle.github.io/react-data-grid/examples.html#/custom-filters -// https://github.com/adazzle/react-data-grid/tree/master/packages/react-data-grid-addons/src/cells/headerCells/filters -const FilterInput = ({ - layerId, - type, - dataKey, - filters, - setDataFilter, - clearDataFilter, -}) => { - const filterValue = filters[dataKey] || '' +const FilterInput = ({ type, dataKey, name }) => { + const dispatch = useDispatch() + const dataTable = useSelector((state) => state.dataTable) + const map = useSelector((state) => state.map) - // https://stackoverflow.com/questions/36683770/react-how-to-get-the-value-of-an-input-field - const onChange = (evt) => { - const value = evt.target.value + const overlay = + dataTable && map.mapViews.filter((layer) => layer.id === dataTable)[0] - if (value !== '') { - setDataFilter(layerId, dataKey, value) - } else { - clearDataFilter(layerId, dataKey, value) - } + let layerId + let filters + if (overlay) { + layerId = overlay.id + filters = overlay.dataFilters || {} } + const filterValue = filters[dataKey] || '' + + const onChange = ({ value }) => + value !== '' + ? dispatch(setDataFilter(layerId, dataKey, value)) + : dispatch(clearDataFilter(layerId, dataKey, value)) + return ( - 3&<8' : 'Search'} // TODO: Support more field types + 3&<8' : i18n.t('Search')} value={filterValue} - onClick={(evt) => evt.stopPropagation()} onChange={onChange} /> ) } FilterInput.propTypes = { - clearDataFilter: PropTypes.func.isRequired, dataKey: PropTypes.string.isRequired, - filters: PropTypes.object.isRequired, - layerId: PropTypes.string.isRequired, - setDataFilter: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, } -// Avoid needing to pass filter and actions to every input field -const mapStateToProps = ({ dataTable, map }) => { - const overlay = dataTable - ? map.mapViews.filter((layer) => layer.id === dataTable)[0] - : null - - if (overlay) { - return { - layerId: overlay.id, - filters: overlay.dataFilters || {}, - } - } - - return null -} - -export default connect(mapStateToProps, { setDataFilter, clearDataFilter })( - FilterInput -) +export default FilterInput diff --git a/src/components/datatable/__tests__/useTableData.spec.js b/src/components/datatable/__tests__/useTableData.spec.js new file mode 100644 index 000000000..9976daadb --- /dev/null +++ b/src/components/datatable/__tests__/useTableData.spec.js @@ -0,0 +1,539 @@ +import { renderHook } from '@testing-library/react-hooks' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { useTableData } from '../useTableData.js' + +jest.mock('../../map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +const mockStore = configureMockStore() + +describe('useTableData', () => { + test('gets headers and rows for facility layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'facility', + dataFilters: null, + data: [ + { + properties: { + id: 'facility-1', + name: 'Facility 1', + type: 'Point', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(4) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Name', dataKey: 'name', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(4) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'Facility 1', dataKey: 'name' }, + { value: 'facility-1', dataKey: 'id' }, + { value: 'Point', dataKey: 'type' }, + ]) + expect(isLoading).toBe(false) + }) + + test('gets headers and rows for orgUnit layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'orgunit-id-1', + name: 'OrgUnitName 1', + type: 'MultiPolygon', + level: 3, + parentName: 'Bo', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(6) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Name', dataKey: 'name', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Level', dataKey: 'level', type: 'number' }, + { name: 'Parent', dataKey: 'parentName', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(6) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'OrgUnitName 1', dataKey: 'name' }, + { value: 'orgunit-id-1', dataKey: 'id' }, + { value: 3, dataKey: 'level' }, + { value: 'Bo', dataKey: 'parentName' }, + { value: 'MultiPolygon', dataKey: 'type' }, + ]) + expect(isLoading).toBe(false) + }) + + test('gets headers and rows for thematic layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + type: 'Point', + id: 'thematicId-1', + name: 'Ngelehun CHC', + level: 4, + parentName: 'Badjia', + color: '#FFFFB2', + legend: 'Great', + range: '90 - 120', + value: 106.3, + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(10) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Name', dataKey: 'name', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Value', dataKey: 'value', type: 'number' }, + { name: 'Legend', dataKey: 'legend', type: 'string' }, + { name: 'Range', dataKey: 'range', type: 'string' }, + { name: 'Level', dataKey: 'level', type: 'number' }, + { name: 'Parent', dataKey: 'parentName', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + { + name: 'Color', + dataKey: 'color', + type: 'string', + renderer: 'rendercolor', + }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(10) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'Ngelehun CHC', dataKey: 'name' }, + { value: 'thematicId-1', dataKey: 'id' }, + { value: 106.3, dataKey: 'value' }, + { value: 'Great', dataKey: 'legend' }, + { value: '90 - 120', dataKey: 'range' }, + { value: 4, dataKey: 'level' }, + { value: 'Badjia', dataKey: 'parentName' }, + { value: 'Point', dataKey: 'type' }, + { value: '#FFFFB2', dataKey: 'color' }, + ]) + expect(isLoading).toBe(false) + }) + + test('gets headers and rows for event layer', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'ps', + column: 'Program stage', + valueType: 'TEXT', + }, + { + name: 'eventdate', + column: 'Report date', + valueType: 'DATE', + }, + { + name: 'lastupdated', + column: 'Last updated on', + valueType: 'DATE', + }, + { + name: 'eventstatus', + column: 'Event status', + valueType: 'TEXT', + }, + { + name: 'oZg33kd9taw', + column: 'Gender', + valueType: 'TEXT', + }, + ], + + data: [ + { + properties: { + id: 'a9712323629', + type: 'Point', + ps: 'pTo4uMt3xur', + eventdate: '2023-05-15 00:00:00.0', + lastupdated: '2018-04-12 20:58:51.31', + ouname: 'Lumley Hospital', + eventstatus: 'ACTIVE', + oZg33kd9taw: 'Female', + value: 'Female', + color: '#ff7f00', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows, isLoading } = result.current + expect(headers).toHaveLength(8) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Org unit', dataKey: 'ouname', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { + name: 'Event time', + dataKey: 'eventdate', + type: 'date', + renderer: 'formatTime...', + }, + { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, + { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, + { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(8) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'Lumley Hospital', dataKey: 'ouname' }, + { value: 'a9712323629', dataKey: 'id' }, + { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, + { value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' }, + { value: 'ACTIVE', dataKey: 'eventstatus' }, + { value: 'Female', dataKey: 'oZg33kd9taw' }, + { value: 'Point', dataKey: 'type' }, + ]) + expect(isLoading).toBe(false) + }) + + test('gets headers and rows for EE population layer', () => { + const store = { + aggregations: { + eelayerid: { + boOU: { + mean: 47.34593724212383, + sum: 851090.567864418, + }, + bombaliOU: { + mean: 27.347820392739166, + sum: 585872.3562736511, + }, + }, + }, + } + + const layer = { + layer: 'earthEngine', + aggregationType: ['sum', 'mean'], + legend: { + title: 'Population', + }, + id: 'eelayerid', + dataFilters: null, + data: [ + { + id: 'boOU', + properties: { + type: 'Polygon', + id: 'boOu', + name: 'Bo', + }, + }, + { + id: 'bombaliOU', + properties: { + type: 'Polygon', + id: 'bombaliOU', + name: 'Bombali', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows, isLoading } = result.current + + expect(headers).toHaveLength(6) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Name', dataKey: 'name', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + { + name: 'Sum Population', + dataKey: 'sum', + // roundFn: Function.prototype, + type: 'number', + }, + { + name: 'Mean Population', + dataKey: 'mean', + // roundFn: Function.prototype, + type: 'number', + }, + ]) + expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveLength(6) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'Bo', dataKey: 'name' }, + { value: 'boOu', dataKey: 'id' }, + { value: 'Polygon', dataKey: 'type' }, + { value: 851091, dataKey: 'sum' }, + { value: 47.35, dataKey: 'mean' }, + ]) + expect(isLoading).toBe(false) + }) + + test('gets headers and rows for EE population age groups layer', () => { + const store = { + aggregations: { + eelayerid: { + badijaOU: { + M_0_mean: 0.4416957503717281, + M_0_sum: 279.5934099853039, + M_1_mean: 1.667343524395007, + M_1_sum: 1055.4284509420395, + M_5_mean: 1.8668244672235907, + M_5_sum: 1181.699887752533, + mean: 3.975863741990326, + sum: 2516.7217486798763, + }, + baomaOU: { + M_0_mean: 0.6669754306043404, + M_0_sum: 1012.4687036573887, + M_1_mean: 2.517744771694477, + M_1_sum: 3821.9365634322166, + M_5_mean: 2.818359887764859, + M_5_sum: 4278.270309627056, + mean: 6.003080090063677, + sum: 9112.675576716661, + }, + }, + }, + } + + const layer = { + layer: 'earthEngine', + name: 'Population age groups', + aggregationType: ['sum', 'mean'], + id: 'eelayerid', + legend: { + title: 'Population age groups', + groups: [ + { + id: 'M_0', + name: 'Male 0 - 1 years', + }, + { + id: 'M_1', + name: 'Male 1 - 4 years', + }, + { + id: 'M_5', + name: 'Male 5 - 9 years', + }, + ], + items: [ + { + color: '#fee5d9', + from: 0, + to: 10, + name: '0 - 10', + }, + { + color: '#fcbba1', + from: 10, + to: 20, + name: '10 - 20', + }, + { + color: '#fc9272', + from: 20, + to: 30, + name: '20 - 30', + }, + { + color: '#fb6a4a', + from: 30, + to: 40, + name: '30 - 40', + }, + { + color: '#de2d26', + from: 40, + to: 50, + name: '40 - 50', + }, + { + color: '#a50f15', + from: 50, + name: '> 50', + }, + ], + }, + data: [ + { + id: 'badijaOU', + properties: { + type: 'Polygon', + id: 'boOU', + name: 'Badija', + }, + }, + { + type: 'Feature', + id: 'baomaOU', + properties: { + type: 'Polygon', + id: 'baomaOU', + name: 'Baoma', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows, isLoading } = result.current + + expect(headers).toHaveLength(6) + expect(headers).toMatchObject([ + { name: 'Index', dataKey: 'index', type: 'number' }, + { name: 'Name', dataKey: 'name', type: 'string' }, + { name: 'Id', dataKey: 'id', type: 'string' }, + { name: 'Type', dataKey: 'type', type: 'string' }, + { + name: 'Sum Population Age Groups', + dataKey: 'sum', + // roundFn: Function.prototype, + type: 'number', + }, + { + name: 'Mean Population Age Groups', + dataKey: 'mean', + // roundFn: Function.prototype, + type: 'number', + }, + ]) + expect(headers[4].roundFn).toBeInstanceOf(Function) + expect(headers[5].roundFn).toBeInstanceOf(Function) + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveLength(6) + expect(rows[0]).toMatchObject([ + { value: 0, dataKey: 'index' }, + { value: 'Badija', dataKey: 'name' }, + { value: 'boOU', dataKey: 'id' }, + { value: 'Polygon', dataKey: 'type' }, + { value: 2517, dataKey: 'sum' }, + { value: 3.976, dataKey: 'mean' }, + ]) + expect(isLoading).toBe(false) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index cd3dff206..85fc33e4f 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -4,13 +4,36 @@ bottom: 0; z-index: 1040; background: #fff; - box-shadow: 0 -2px 3px rgba(0, 0, 0, 0.1); +} + +.dataTableControls { + width: 100%; + height: 20px; + background-color: var(--colors-grey100); + position: relative; } .closeIcon { position: absolute; - right: 3px; + top: 0; + right: 2px; z-index: 100; cursor: pointer; color: var(--colors-grey800); + background-color: var(--colors-grey100); + width: 20px; + height: 20px; + border: none; +} + +.closeIcon:hover { + color: var(--colors-grey900); + background-color: var(--colors-grey300); +} + +.closeIcon svg { + position:absolute; + top: 50%; + left: 50%; + margin: -8px 0 0 -8px; } diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index b624f85ce..b5a3e6e4c 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -1,74 +1,60 @@ .dataTable { - font-size: 11px; - font-family: Roboto, sans-serif; - overflow: hidden; + height: 1px; } -.noSupport { - position: absolute; - top: 50%; - left: 50%; - transform: translateX(-50%) translateY(-50%); - color: #333; - font-style: italic; - line-height: 30px; -} - -.loader { - position: absolute; - width: 100%; - height: 100%; - top: 0; +.dataTable > :global(thead) { + user-select: none; } -:global(.ReactVirtualized__Table__headerRow) { - position: relative; - background-color: #eee; - box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 6px, rgba(0, 0, 0, 0.12) 0px 1px 2px; - z-index: 10; +td.dataCell { + padding-top: var(--spacers-dp8); + padding-bottom: var(--spacers-dp8); + font-size: 11px; } -:global(.ReactVirtualized__Table__headerRow .ColumnHeader-label) { - white-space: nowrap; +td.dataCell:hover { + cursor: default; } -:global(.ReactVirtualized__Table__headerTruncatedText) { - display: inline-block; - position: absolute; - top: 7px; - left: 10px; - right: 15px; - user-select: none; - overflow: hidden; +td.lightText { + color: var(--colors-white); } -:global(.ReactVirtualized__Table__sortableHeaderIcon) { - position: absolute; - top: 5px; - right: 0; +.columnHeader > :global(span.container) { + justify-content: space-between; } -:global(.ReactVirtualized__Table__headerColumn) { - height: 48px; - margin-bottom: 2px; - position: relative; +.columnHeader :global(input.dense) { + padding: 4px 6px; } -:global(.ReactVirtualized__Table__headerColumn:focus) { - outline: none; +.columnHeader :global(input::placeholder) { + color: var(--colors-grey400); } -:global(.ReactVirtualized__Table__row) { - position: relative; - border-bottom: 1px solid #e0e0e0; - z-index: 9; +/* Hide the filter icon */ +.columnHeader + > :global(span.container) + > :global(span.top) + > button:last-of-type { + visibility: hidden; } -:global(.ReactVirtualized__Table__row:hover) { - background-color: #fafafa; - cursor: pointer; +.noResults { + display: flex; + color: var(--colors-grey600); + align-items: center; + justify-content: center; + font-style: italic; + min-height: 40px; } -:global(.ReactVirtualized__Table__rowColumn.right) { - text-align: right; +.noSupport { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + color: #333; + font-style: italic; + line-height: 30px; } diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css deleted file mode 100644 index 91807f2e2..000000000 --- a/src/components/datatable/styles/FilterInput.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.filterInput { - position: absolute; - left: 0; - top: 22px; - box-sizing: border-box; - width: 100%; - height: 24px; - font-weight: normal; - padding-left: 5px; -} - -.filterInput::placeholder { - color: #ddd; -} diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css index e3f5952ee..27c0465ab 100644 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ b/src/components/datatable/styles/ResizeHandle.module.css @@ -2,27 +2,18 @@ display: flex; justify-content: center; align-items: center; - position: absolute; - left: 50%; - top: -8px; - box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.2); - width: 40px; - height: 12px; - background: #fff; + width: 100%; + height: 100%; z-index: 1500; cursor: grab; - cursor: -moz-grab; - cursor: -webkit-grab; } .resizeHandle:hover, .resizeHandle:active { - transform: scale(1.03); - box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2); + background-color: var(--colors-grey300); + color: var(--colors-grey900); } .resizeHandle:active { cursor: grabbing; - cursor: -moz-grabbing; - cursor: -webkit-grabbing; } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js new file mode 100644 index 000000000..42e61bf0c --- /dev/null +++ b/src/components/datatable/useTableData.js @@ -0,0 +1,282 @@ +import i18n from '@dhis2/d2-i18n' +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { + EVENT_LAYER, + THEMATIC_LAYER, + ORG_UNIT_LAYER, + EARTH_ENGINE_LAYER, + FACILITY_LAYER, +} from '../../constants/layers.js' +import { numberValueTypes } from '../../constants/valueTypes.js' +import { hasClasses, getPrecision } from '../../util/earthEngine.js' +import { filterData } from '../../util/filter.js' +import { isValidUid } from '../../util/helpers.js' +import { numberPrecision } from '../../util/numbers.js' + +const ASCENDING = 'asc' + +const TYPE_NUMBER = 'number' +const TYPE_STRING = 'string' +const TYPE_DATE = 'date' + +const INDEX = 'index' +const NAME = 'name' +const ID = 'id' +const VALUE = 'value' +const LEGEND = 'legend' +const RANGE = 'range' +const LEVEL = 'level' +const PARENT_NAME = 'parentName' +const TYPE = 'type' +const COLOR = 'color' +const OUNAME = 'ouname' +const EVENTDATE = 'eventdate' + +const defaultFieldsMap = () => ({ + [INDEX]: { name: i18n.t('Index'), dataKey: INDEX, type: TYPE_NUMBER }, + [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, + [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, + [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, + [PARENT_NAME]: { + name: i18n.t('Parent'), + dataKey: PARENT_NAME, + type: TYPE_STRING, + }, + [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER }, + [LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING }, + [RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING }, + [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING }, + [EVENTDATE]: { + name: i18n.t('Event time'), + dataKey: EVENTDATE, + type: TYPE_DATE, + renderer: 'formatTime...', + }, + [COLOR]: { + name: i18n.t('Color'), + dataKey: COLOR, + type: TYPE_STRING, + renderer: 'rendercolor', + }, +}) + +const getThematicHeaders = () => + [ + INDEX, + NAME, + ID, + VALUE, + LEGEND, + RANGE, + LEVEL, + PARENT_NAME, + TYPE, + COLOR, + ].map((field) => defaultFieldsMap()[field]) + +const getEventHeaders = ({ layerHeaders = [], styleDataItem }) => { + const fields = [INDEX, OUNAME, ID, EVENTDATE].map( + (field) => defaultFieldsMap()[field] + ) + + const customFields = layerHeaders + .filter(({ name }) => isValidUid(name)) + .map(({ name: dataKey, column: name, valueType }) => ({ + name, + dataKey, + type: numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, + })) + + customFields.push(defaultFieldsMap()[TYPE]) + + if (styleDataItem) { + customFields.push(defaultFieldsMap()[COLOR]) + } + + return fields.concat(customFields) +} + +const getOrgUnitHeaders = () => + [INDEX, NAME, ID, LEVEL, PARENT_NAME, TYPE].map( + (field) => defaultFieldsMap()[field] + ) + +const getFacilityHeaders = () => + [INDEX, NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) + +const toTitleCase = (str) => + str.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + +const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { + const { title, items } = legend + + let customFields = [] + + if (hasClasses(aggregationType) && items) { + customFields = items.map(({ id, name }) => ({ + name, + dataKey: String(id), + roundFn: numberPrecision(2), + type: TYPE_NUMBER, + })) + } else if (Array.isArray(aggregationType) && aggregationType.length) { + customFields = aggregationType.map((type) => { + let roundFn = null + if (data?.length) { + const precision = getPrecision(data.map((d) => d[type])) + roundFn = numberPrecision(precision) + } + return { + name: toTitleCase(`${type} ${title}`), + dataKey: type, + roundFn, + type: TYPE_NUMBER, + } + }) + } + + return [INDEX, NAME, ID, TYPE] + .map((field) => defaultFieldsMap()[field]) + .concat(customFields) +} + +const EMPTY_AGGREGATIONS = {} +const EMPTY_LAYER = {} + +export const useTableData = ({ layer, sortField, sortDirection }) => { + const allAggregations = useSelector((state) => state.aggregations) + const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS + + const { + layer: layerType, + aggregationType, + legend, + styleDataItem, + data, + dataFilters, + headers: layerHeaders, + serverCluster, + } = layer || EMPTY_LAYER + + const dataWithAggregations = useMemo(() => { + if (!data || serverCluster) { + return null + } + return data + .map((d, i) => ({ + index: i, + ...d, + })) + .filter((d) => !d.properties.hasAdditionalGeometry) + .map((d, i) => ({ + ...(d.properties || d), + ...aggregations[d.id], + index: d.index, + i, + })) + }, [data, aggregations, serverCluster]) + + const headers = useMemo(() => { + if (dataWithAggregations === null) { + return null + } + + switch (layerType) { + case THEMATIC_LAYER: + return getThematicHeaders() + case EVENT_LAYER: + return getEventHeaders({ layerHeaders, styleDataItem }) + case ORG_UNIT_LAYER: + return getOrgUnitHeaders() + case EARTH_ENGINE_LAYER: + return getEarthEngineHeaders({ + aggregationType, + legend, + data: dataWithAggregations, + }) + case FACILITY_LAYER: + return getFacilityHeaders() + default: { + return null + } + } + }, [ + layerType, + aggregationType, + legend, + styleDataItem, + dataWithAggregations, + layerHeaders, + ]) + + const rows = useMemo(() => { + if (dataWithAggregations === null || headers === null) { + return null + } + + if (!dataWithAggregations.length || !headers?.length) { + return [] + } + + const filteredData = filterData(dataWithAggregations, dataFilters) + + //sort + filteredData.sort((a, b) => { + a = a[sortField] + b = b[sortField] + + if (typeof a === TYPE_NUMBER) { + return sortDirection === ASCENDING ? a - b : b - a + } + // TODO: Make sure sorting works across different locales - use lib method + if (a !== undefined) { + return sortDirection === ASCENDING + ? a.localeCompare(b) + : b.localeCompare(a) + } + + return 0 + }) + + return filteredData.map((item) => + headers.map(({ dataKey, roundFn, type }) => { + const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] + + return { + dataKey, + value: type === TYPE_NUMBER && isNaN(value) ? null : value, + align: type === TYPE_NUMBER ? 'right' : 'left', + } + }) + ) + }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection]) + + // EE layers and event layers may be loading additional data + const isLoading = + (layerType === EARTH_ENGINE_LAYER && + aggregationType?.length && + (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || + (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) + + let error = null + if (serverCluster) { + error = i18n.t( + 'Data table is not supported when events are grouped on the server.' + ) + } else if (dataWithAggregations === null) { + error = i18n.t( + 'No valid data was found for the current layer configuration.' + ) + } else if (headers === null) { + error = i18n.t('Data table is not supported for this layer type.') + } + + return { headers, rows, isLoading, error } +} diff --git a/src/components/layers/toolbar/LayerToolbarMoreMenu.js b/src/components/layers/toolbar/LayerToolbarMoreMenu.js index 619598f76..b2b3846bf 100644 --- a/src/components/layers/toolbar/LayerToolbarMoreMenu.js +++ b/src/components/layers/toolbar/LayerToolbarMoreMenu.js @@ -40,6 +40,9 @@ const LayerToolbarMoreMenu = ({ return null } + const showDataTableDisabled = + !hasOrgUnitData && (!dataTableOpen || dataTableOpen !== layer.id) + return ( <>
)} {openAs && ( diff --git a/src/components/loaders/EventLoader.js b/src/components/loaders/EventLoader.js index 2230658da..df45f6be2 100644 --- a/src/components/loaders/EventLoader.js +++ b/src/components/loaders/EventLoader.js @@ -1,11 +1,13 @@ import PropTypes from 'prop-types' import { useEffect } from 'react' +import { useSelector } from 'react-redux' import eventLoader from '../../loaders/eventLoader.js' const EventLoader = ({ config, onLoad }) => { + const dataTableOpen = useSelector((state) => !!state.dataTable) useEffect(() => { - eventLoader(config).then(onLoad) - }, [config, onLoad]) + eventLoader(config, dataTableOpen).then(onLoad) + }, [config, onLoad, dataTableOpen]) return null } diff --git a/src/components/loaders/LayersLoader.js b/src/components/loaders/LayersLoader.js index b703672a1..36396215c 100644 --- a/src/components/loaders/LayersLoader.js +++ b/src/components/loaders/LayersLoader.js @@ -1,29 +1,44 @@ import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setLayerLoading, updateLayer } from '../../actions/layers.js' +import { EVENT_LAYER } from '../../constants/layers.js' import LayerLoader from './LayerLoader.js' const LayersLoader = () => { - const layers = useSelector((state) => - state.map.mapViews.filter((layer) => { - // The layer is currently being loaded - don't load again - if (layer.isLoading) { - return false - } else { - // The layer is not loaded - load it - if (!layer.isLoaded) { - return true + const layers = useSelector(({ map, dataTable }) => + map.mapViews.filter( + ({ + id, + isLoading, + isLoaded, + isExtended, + layer: layerType, + serverCluster, + }) => { + // The layer is currently being loaded - don't load again + if (isLoading) { + return false + } else { + // The layer is not loaded - load it + if (!isLoaded) { + return true + } + + // The layer is loaded but the data table is now displayed and + // event extended data hasn't been loaded yet - so load it + if ( + layerType === EVENT_LAYER && + id === dataTable && + !isExtended && + !serverCluster + ) { + return true + } + + return false } - - // The layer is loaded but the data table is now displayed and - // event extended data hasn't been loaded yet - so load it - if (layer.showDataTable && !layer.isExtended) { - return true - } - - return false } - }) + ) ) const dispatch = useDispatch() diff --git a/src/components/loaders/__tests__/LayersLoader.spec.js b/src/components/loaders/__tests__/LayersLoader.spec.js index f9d933a89..f8a9fc4b9 100644 --- a/src/components/loaders/__tests__/LayersLoader.spec.js +++ b/src/components/loaders/__tests__/LayersLoader.spec.js @@ -2,10 +2,13 @@ import { render } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { EVENT_LAYER } from '../../../constants/layers.js' import LayersLoader from '../LayersLoader.js' const mockStore = configureMockStore() +const id = 'thelayerid' + jest.mock( '../LayerLoader.js', () => @@ -16,24 +19,26 @@ jest.mock( const testCases = [ { - testTitle: 'renders 0 layers if no layers', + testTitle: 'renders 0 layerloaders if no layers', store: { map: { mapViews: [], }, + dataTable: null, }, }, { - testTitle: 'renders 0 layers if currently loading the layer', + testTitle: 'renders 0 layerloaders if currently loading the layer', store: { map: { mapViews: [{ isLoading: true, isLoaded: false }], }, + dataTable: null, }, }, { testTitle: - 'renders 0 layers if currently loading regardless of whether loaded already', + 'renders 0 layerloaders if currently loading regardless of whether loaded already', store: { map: { mapViews: [ @@ -43,10 +48,12 @@ const testCases = [ }, ], }, + dataTable: null, }, }, { - testTitle: 'renders 1 layer if not currently loading and not loaded', + testTitle: + 'renders 1 layerloader if not currently loading and not loaded', store: { map: { mapViews: [ @@ -56,45 +63,52 @@ const testCases = [ }, ], }, + dataTable: null, }, }, { testTitle: - 'renders 1 layer if not currently loading and is loaded but need extended data', + 'renders 1 layerloader if not currently loading and is loaded but need extended data', store: { map: { mapViews: [ { + id, + layer: EVENT_LAYER, isLoading: false, isLoaded: true, - showDataTable: true, isExtended: false, + serverCluster: false, }, ], }, + dataTable: id, }, }, { testTitle: - 'renders 0 layers if not currently loading and loaded and extended data also loaded', + 'renders 0 layerloaders if not currently loading and loaded and extended data also loaded', store: { map: { mapViews: [ { + id, + layer: EVENT_LAYER, isLoading: false, isLoaded: true, - showDataTable: true, isExtended: true, + serverCluster: false, }, ], }, + dataTable: id, }, }, ] describe('LayersLoader', () => { testCases.forEach(({ testTitle, store }) => { - test(testTitle, () => { + it(testTitle, () => { const { container } = render( diff --git a/src/components/loaders/__tests__/__snapshots__/LayersLoader.spec.js.snap b/src/components/loaders/__tests__/__snapshots__/LayersLoader.spec.js.snap index 90197f2b9..e8c7db59e 100644 --- a/src/components/loaders/__tests__/__snapshots__/LayersLoader.spec.js.snap +++ b/src/components/loaders/__tests__/__snapshots__/LayersLoader.spec.js.snap @@ -1,14 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`LayersLoader renders 0 layers if currently loading regardless of whether loaded already 1`] = `
`; +exports[`LayersLoader renders 0 layerloaders if currently loading regardless of whether loaded already 1`] = `
`; -exports[`LayersLoader renders 0 layers if currently loading the layer 1`] = `
`; +exports[`LayersLoader renders 0 layerloaders if currently loading the layer 1`] = `
`; -exports[`LayersLoader renders 0 layers if no layers 1`] = `
`; +exports[`LayersLoader renders 0 layerloaders if no layers 1`] = `
`; -exports[`LayersLoader renders 0 layers if not currently loading and loaded and extended data also loaded 1`] = `
`; +exports[`LayersLoader renders 0 layerloaders if not currently loading and loaded and extended data also loaded 1`] = `
`; -exports[`LayersLoader renders 1 layer if not currently loading and is loaded but need extended data 1`] = ` +exports[`LayersLoader renders 1 layerloader if not currently loading and is loaded but need extended data 1`] = `
LayerLoader @@ -16,7 +16,7 @@ exports[`LayersLoader renders 1 layer if not currently loading and is loaded but
`; -exports[`LayersLoader renders 1 layer if not currently loading and not loaded 1`] = ` +exports[`LayersLoader renders 1 layerloader if not currently loading and not loaded 1`] = `
LayerLoader diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index c53eeb80c..1761728b7 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -7,6 +7,7 @@ import { EVENT_COLOR, EVENT_RADIUS, } from '../constants/layers.js' +import { numberValueTypes } from '../constants/valueTypes.js' import { getFiltersFromColumns, getFiltersAsText, @@ -16,6 +17,7 @@ import { import { cssColor, getContrastColor } from '../util/colors.js' import { getAnalyticsRequest, loadData } from '../util/event.js' import { getBounds } from '../util/geojson.js' +import { isValidUid } from '../util/helpers.js' import { styleByDataItem } from '../util/styleByDataItem.js' import { formatStartEndDate, getDateArray } from '../util/time.js' @@ -37,10 +39,10 @@ const unknownErrorAlert = { // TODO: Refactor to share code with other loaders // Returns a promise -const eventLoader = async (layerConfig) => { +const eventLoader = async (layerConfig, loadExtended) => { const config = { ...layerConfig } try { - await loadEventLayer(config) + await loadEventLayer(config, loadExtended) } catch (e) { if (e.httpStatusCode === 403 || e.httpStatusCode === 409) { config.alerts = [ @@ -61,7 +63,7 @@ const eventLoader = async (layerConfig) => { return config } -const loadEventLayer = async (config) => { +const loadEventLayer = async (config, loadExtended) => { const { columns, endDate, @@ -74,14 +76,13 @@ const loadEventLayer = async (config) => { startDate, styleDataItem, areaRadius, - showDataTable, } = config const period = getPeriodFromFilters(filters) const dataFilters = getFiltersFromColumns(columns) const d2 = await getD2() - config.isExtended = showDataTable + config.isExtended = loadExtended const analyticsRequest = await getAnalyticsRequest(config, { d2, @@ -160,6 +161,26 @@ const loadEventLayer = async (config) => { }) config.headers = response.headers + + const numericDataItemHeaders = config.headers.filter( + (header) => + isValidUid(header.name) && + numberValueTypes.includes(header.valueType) + ) + + if (numericDataItemHeaders.length) { + config.data = config.data.map((d) => { + const newD = { ...d } + + numericDataItemHeaders.forEach((header) => { + newD.properties[header.name] = parseFloat( + d.properties[header.name] + ) + }) + + return newD + }) + } } if (!styleDataItem) { diff --git a/yarn.lock b/yarn.lock index 7add303b8..452847445 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,14 +26,7 @@ dependencies: "@babel/highlight" "^7.10.4" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/code-frame@^7.22.13": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.8.3": version "7.22.13" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e" integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== @@ -76,17 +69,7 @@ eslint-visitor-keys "^2.1.0" semver "^6.3.0" -"@babel/generator@^7.21.0", "@babel/generator@^7.7.2": - version "7.21.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd" - integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA== - dependencies: - "@babel/types" "^7.21.0" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/generator@^7.23.0": +"@babel/generator@^7.21.0", "@babel/generator@^7.23.0", "@babel/generator@^7.7.2": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420" integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g== @@ -156,12 +139,7 @@ resolve "^1.14.2" semver "^6.1.2" -"@babel/helper-environment-visitor@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" - integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== - -"@babel/helper-environment-visitor@^7.22.20": +"@babel/helper-environment-visitor@^7.18.9", "@babel/helper-environment-visitor@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== @@ -173,15 +151,7 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0", "@babel/helper-function-name@^7.21.0": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz#d552829b10ea9f120969304023cd0645fa00b1b4" - integrity sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg== - dependencies: - "@babel/template" "^7.20.7" - "@babel/types" "^7.21.0" - -"@babel/helper-function-name@^7.23.0": +"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0", "@babel/helper-function-name@^7.21.0", "@babel/helper-function-name@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== @@ -189,14 +159,7 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-hoist-variables@^7.22.5": +"@babel/helper-hoist-variables@^7.18.6", "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== @@ -279,36 +242,19 @@ dependencies: "@babel/types" "^7.20.0" -"@babel/helper-split-export-declaration@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" - integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-split-export-declaration@^7.22.6": +"@babel/helper-split-export-declaration@^7.18.6", "@babel/helper-split-export-declaration@^7.22.6": version "7.22.6" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== dependencies: "@babel/types" "^7.22.5" -"@babel/helper-string-parser@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" - integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== - "@babel/helper-string-parser@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== -"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/helper-validator-identifier@^7.22.20": +"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== @@ -337,16 +283,7 @@ "@babel/traverse" "^7.21.0" "@babel/types" "^7.21.0" -"@babel/highlight@^7.10.4", "@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/highlight@^7.22.13": +"@babel/highlight@^7.10.4", "@babel/highlight@^7.22.13": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54" integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg== @@ -355,12 +292,7 @@ chalk "^2.4.2" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.7.0": - version "7.21.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3" - integrity sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ== - -"@babel/parser@^7.22.15", "@babel/parser@^7.23.0": +"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.22.15", "@babel/parser@^7.23.0", "@babel/parser@^7.7.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719" integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== @@ -1141,30 +1073,14 @@ core-js "^2.6.12" regenerator-runtime "^0.13.11" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.0", "@babel/runtime@^7.16.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" - integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== - dependencies: - regenerator-runtime "^0.13.11" - -"@babel/runtime@^7.7.6": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.0", "@babel/runtime@^7.16.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682" integrity sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ== dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" - integrity sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - -"@babel/template@^7.22.15": +"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15", "@babel/template@^7.3.3": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w== @@ -1197,16 +1113,7 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": - version "7.21.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.4.tgz#2d5d6bb7908699b3b416409ffd3b5daa25b030d4" - integrity sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA== - dependencies: - "@babel/helper-string-parser" "^7.19.4" - "@babel/helper-validator-identifier" "^7.19.1" - to-fast-properties "^2.0.0" - -"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0": +"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.4", "@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== @@ -3308,6 +3215,14 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.1.2", "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -5507,7 +5422,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -8308,12 +8223,7 @@ flush-write-stream@^1.0.2: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0, follow-redirects@^1.14.7, follow-redirects@^1.14.9: - version "1.15.2" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" - integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== - -follow-redirects@^1.15.0: +follow-redirects@^1.0.0, follow-redirects@^1.14.7, follow-redirects@^1.14.9, follow-redirects@^1.15.0: version "1.15.3" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== @@ -13410,6 +13320,13 @@ react-dom@^16.14.0, react-dom@^16.8.6: prop-types "^15.6.2" scheduler "^0.19.1" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.11: version "6.0.11" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" @@ -13580,6 +13497,11 @@ react-virtualized@^9.22.5: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" +react-virtuoso@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.6.2.tgz#74b59ebe3260e1f73e92340ffec84a6853285a12" + integrity sha512-vvlqvzPif+MvBrJ09+hJJrVY0xJK9yran+A+/1iwY78k0YCVKsyoNPqoLxOxzYPggspNBNXqUXEcvckN29OxyQ== + react@^16.14.0, react@^16.8.6: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" @@ -14125,14 +14047,7 @@ rxjs@^6.6.0: dependencies: tslib "^1.9.0" -rxjs@^7.5.1: - version "7.8.0" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" - integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== - dependencies: - tslib "^2.1.0" - -rxjs@^7.8.1: +rxjs@^7.5.1, rxjs@^7.8.1: version "7.8.1" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== @@ -15539,16 +15454,11 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1: version "2.6.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410" integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"