diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index d61604281b..4bf5365707 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -1,6 +1,63 @@ import { EXTENDED_TIMEOUT } from '../support/util.js' import { Layer } from './layer.js' +// Waits for the popper's text to stabilize before clicking. +const clickStablePopperItem = (text) => { + let lastSignature = null + let stableCount = 0 + + cy.get( + '[data-test="dhis2-uicore-popper"]:visible', + EXTENDED_TIMEOUT + ).should(($popper) => { + const signature = $popper.text() + if (signature === lastSignature) { + stableCount++ + } else { + stableCount = 0 + lastSignature = signature + } + expect(stableCount, 'popper content settled').to.be.at.least(1) + }) + + cy.get('[data-test="dhis2-uicore-popper"]:visible') + .containsExact(text) + .click() +} + +const MAX_SELECT_ATTEMPTS = 3 + +// Selects targetText, then retries the whole open+click if it didn't take - +// clickStablePopperItem() narrows the detached-click race but doesn't close it. +const selectPopperOption = ( + contentDataTest, + targetText, + attemptsLeft = MAX_SELECT_ATTEMPTS +) => { + cy.getByDataTest(contentDataTest, EXTENDED_TIMEOUT).then(($element) => { + if ($element.text().trim() === targetText) { + return + } + + cy.getByDataTest(contentDataTest).click() + clickStablePopperItem(targetText) + + cy.getByDataTest(contentDataTest).then(($after) => { + if ($after.text().trim() !== targetText) { + expect( + attemptsLeft, + `select "${targetText}" eventually took effect` + ).to.be.greaterThan(1) + selectPopperOption( + contentDataTest, + targetText, + attemptsLeft - 1 + ) + } + }) + }) +} + export class EventLayer extends Layer { selectProgram(program) { cy.get('[data-test="programselect"]', EXTENDED_TIMEOUT).click() @@ -21,19 +78,17 @@ export class EventLayer extends Layer { cy.getByDataTest('coordinatefield-content', EXTENDED_TIMEOUT).should( ($el) => expect($el.text().trim().length).to.be.greaterThan(0) ) + selectPopperOption('coordinatefield-content', coordinate) - cy.getByDataTest('coordinatefield-content').then(($element) => { - // Check if the coordinate is already selected by looking at the text content - if ($element.text().trim() !== coordinate) { - cy.log('Select the coordinate') - cy.getByDataTest('coordinatefield-content').click() - cy.getByDataTest('dhis2-uicore-popper') - .containsExact(coordinate) - .click() - } else { - cy.log('Coordinate already selected, no action needed') - } - }) + return this + } + + selectFallbackCoordinate(coordinate) { + cy.getByDataTest( + 'eventcoordinatefieldfallback-content', + EXTENDED_TIMEOUT + ).should(($el) => expect($el.text().trim().length).to.be.greaterThan(0)) + selectPopperOption('eventcoordinatefieldfallback-content', coordinate) return this } @@ -76,4 +131,24 @@ export class EventLayer extends Layer { return this } + + selectCountEventsWithoutCoordinates() { + cy.getByDataTest('eventdialog-styletab') + .contains('Count events without coordinates') + .click() + + return this + } + + selectLabelField(name) { + cy.getByDataTest('eventdialog-styletab').then(($tab) => { + if (!$tab.find('[data-test="label-field-select-content"]').length) { + cy.wrap($tab).contains('Labels').click() + } + }) + + selectPopperOption('label-field-select-content', name) + + return this + } } diff --git a/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js b/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js new file mode 100644 index 0000000000..2459998c75 --- /dev/null +++ b/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js @@ -0,0 +1,623 @@ +// Exhaustive check of Event layer coordinateField/fallbackCoordinateField +// resolution, using test data from the test-data-event-layer-coordinates +// tool in dhis2/maps-tools. +// +// Prerequisites: run that tool's index.js and runAnalytics.js against the +// target instance, with --fixturesRepo= (see its +// README) - this writes cypress/fixtures/eventCoordinateFallbackScenarios.json +// and eventCoordinateFallbackFieldIds.json, which this spec reads directly (a +// missing/stale fixture fails cy.fixture() with a clear error). Also point +// cypress.env.json / the app dev server at the same instance. +// +// For each program (event-only and tracker), cycles through every main +// coordinate field crossed with every fallback option CoordinateField.jsx +// offers for it. Each combo is checked two ways against an expectation +// computed here from the fixtures (same resolution rule - main field +// first, then fallback - verify.js uses): the legend's "events without +// coordinates" count, and - more strongly - the data table's exact set of +// scenario codes with no Point geometry. The count alone could pass by +// coincidence if the wrong events resolved; the exact-set check can't. +// +// Slow (30-60+ min), so it's marked "include": false in +// cypress/support/cypressFiles.json. Run directly: +// yarn cypress run --spec cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js + +import { EventLayer } from '../../elements/event_layer.js' +import { EXTENDED_TIMEOUT } from '../../support/util.js' + +Cypress.on('uncaught:exception', (err) => { + if ( + err.message.includes( + 'ResizeObserver loop completed with undelivered notifications.' + ) + ) { + return false + } +}) + +const LABELS = { + EVENT_LOCATION: 'Event location', + ORG_UNIT_LOCATION: 'Organisation unit location', + ENROLLMENT_LOCATION: 'Enrollment location', + TRACKED_ENTITY_LOCATION: 'Tracked entity location', + CUSTOM_COORDINATE_DE: 'Maps Test Coordinate DE', + CUSTOM_ORGUNIT_DE: 'Maps Test OrgUnit DE', + CUSTOM_COORDINATE_TEA: 'Maps Test Coordinate TEA', + CUSTOM_ORGUNIT_TEA: 'Maps Test OrgUnit TEA', + CASCADING: 'Cascading', +} +const NONE_LABEL = 'None' +const SCENARIO_CODE_COLUMN = 'Maps Test Scenario Code DE' + +// The default org unit selection and relative period don't cover the test +// data's org units/dates, so both need to be set explicitly. The wide fixed +// range mirrors verify.js's own START_DATE/END_DATE, so it keeps working +// regardless of which day the data was imported. +const TEST_ORG_UNITS = [ + 'Maps Test OU (with geometry)', + 'Maps Test OU (no geometry)', +] +const PERIOD_START_DATE = '2020-01-01' +const PERIOD_END_DATE = '2030-12-31' + +const EVENT_PROGRAM_MAIN_FIELDS = [ + 'EVENT_LOCATION', + 'ORG_UNIT_LOCATION', + 'CUSTOM_COORDINATE_DE', + 'CUSTOM_ORGUNIT_DE', +] +const TRACKER_PROGRAM_MAIN_FIELDS = [ + 'EVENT_LOCATION', + 'ORG_UNIT_LOCATION', + 'ENROLLMENT_LOCATION', + 'TRACKED_ENTITY_LOCATION', + 'CUSTOM_COORDINATE_DE', + 'CUSTOM_ORGUNIT_DE', + 'CUSTOM_COORDINATE_TEA', + 'CUSTOM_ORGUNIT_TEA', +] + +// Custom ORGANISATION_UNIT-type fields need 2.44+ as a fallback (see +// src/util/versionToggle.js). Duplicated here rather than imported, since +// app source can't be required into this browser-run spec. +// +// A separate, unrelated gate (CoordinateField.jsx) controls whether such a +// field appears as a MAIN field at all: 2.40.8+, 2.41.4+, or 2.42+. Not +// modeled here - assumed satisfied by whatever instance this runs against. +// If it isn't, selectCoordinate()/selectPopperOption() fail loudly (option +// not found) rather than passing incorrectly. +const ORG_UNIT_TYPE_FIELDS = ['CUSTOM_ORGUNIT_DE', 'CUSTOM_ORGUNIT_TEA'] + +const getServerMinorVersion = () => { + const v = String(Cypress.env('dhis2InstanceVersion') || '') + if (v.toLowerCase() === 'dev') { + return Infinity + } + const match = v.match(/(\d+)\.(\d+)/) + return match ? parseInt(match[2], 10) : parseInt(v, 10) +} + +const supportsOrgUnitFallback = getServerMinorVersion() >= 44 + +// Builds every {mainField, fallbackField} combo the app's CoordinateField.jsx +// would offer for this field set: for each main field, fallback options are +// none, cascading, and every other field (minus org-unit-type fields when +// unsupported) - mirrors verify.js's buildFullTestCases(). +const buildCombos = (mainFields) => + mainFields.flatMap((mainField) => { + const otherFields = mainFields.filter( + (field) => + field !== mainField && + (supportsOrgUnitFallback || + !ORG_UNIT_TYPE_FIELDS.includes(field)) + ) + return [null, 'CASCADING', ...otherFields].map((fallbackField) => ({ + mainField, + fallbackField, + })) + }) + +const labelOf = (fieldKey) => (fieldKey ? LABELS[fieldKey] : NONE_LABEL) + +const comboTitle = (combo) => + `main=${labelOf(combo.mainField)} fallback=${labelOf(combo.fallbackField)}` + +// Loaded once in the outer before() below, from cypress/fixtures/ - see +// maps-tools' test-data-event-layer-coordinates/idMap.js and fieldIds.js, +// which write those fixtures directly (this spec never reads that tool's +// own files). +let scenarios +let fieldIds + +const PROGRAM_NAMES = { + event: 'Maps Test Coordinates (Event)', + tracker: 'Maps Test Coordinates (Tracker)', +} + +const CASCADING_ID = 'cascading' +const CASCADE_WITH_TEI = [ + 'psigeometry', + 'pigeometry', + 'teigeometry', + 'ougeometry', +] +const CASCADE_WITHOUT_TEI = ['psigeometry', 'ougeometry'] + +// Mirrors resolve.js's cascade order and resolution rule (main field first, +// then the fallback) - kept in sync manually since this spec intentionally +// doesn't import that script. +const resolveExpectedField = ({ points, hasTei, mainField, fallbackField }) => { + if (points[mainField]) { + return mainField + } + if (fallbackField === CASCADING_ID) { + const chain = hasTei ? CASCADE_WITH_TEI : CASCADE_WITHOUT_TEI + return chain.find((candidate) => points[candidate]) ?? null + } + if (fallbackField && points[fallbackField]) { + return fallbackField + } + return null +} + +// Maps the spec's semantic field keys to the real field id +// resolveExpectedField() needs: fixed strings for the built-in fields, and +// this run's generated custom DE/TEA ids (from the fieldIds fixture). +const resolveFieldId = (key) => { + if (key === null || key === 'NONE') { + return null + } + const map = { + EVENT_LOCATION: 'psigeometry', + ORG_UNIT_LOCATION: 'ougeometry', + ENROLLMENT_LOCATION: 'pigeometry', + TRACKED_ENTITY_LOCATION: 'teigeometry', + CASCADING: CASCADING_ID, + CUSTOM_COORDINATE_DE: fieldIds.deCoordinate, + CUSTOM_ORGUNIT_DE: fieldIds.deOrgUnit, + CUSTOM_COORDINATE_TEA: fieldIds.teaCoordinate, + CUSTOM_ORGUNIT_TEA: fieldIds.teaOrgUnit, + } + if (!(key in map)) { + throw new Error(`Unknown scenario field key: ${key}`) + } + return map[key] +} + +const getScenarioEvents = (program) => { + const programName = PROGRAM_NAMES[program] + const events = scenarios.filter( + (e) => e.type === 'event' && e.program === programName + ) + if (events.length === 0) { + throw new Error( + `No "${programName}" events found in the scenarios fixture` + ) + } + return events +} + +// Computes, for a program + main/fallback combo, the set of scenario codes +// expected to end up with no geometry (and the program's total event +// count) - the single source of truth both assertions below check against. +const resolveComboExpectation = ({ + program, + hasTei, + mainField, + fallbackField, +}) => { + const events = getScenarioEvents(program) + const mainId = resolveFieldId(mainField) + const fallbackId = resolveFieldId(fallbackField) + + const withoutCoordsCodes = new Set() + events.forEach((event) => { + const field = resolveExpectedField({ + points: event.points, + hasTei, + mainField: mainId, + fallbackField: fallbackId, + }) + if (field === null) { + withoutCoordsCodes.add(event.scenarioCode) + } + }) + + return { total: events.length, withoutCoordsCodes } +} + +// Retries (like cy.get().should(cb)) rather than a one-shot read, since the +// legend text updates asynchronously after the layer reloads. +const assertEventsWithoutCoordinatesCount = (expected) => { + cy.getByDataTest('layerlegend', EXTENDED_TIMEOUT).should(($legend) => { + const text = $legend.text() + let actual + if (/all events have coordinates/i.test(text)) { + actual = 0 + } else { + const match = text.match( + /([\d,. ]+)\s*events? without coordinates/i + ) + expect(match, `"events without coordinates" text in: ${text}`).to + .not.be.null + actual = parseInt(match[1].replace(/\D/g, ''), 10) + } + expect(actual, 'events without coordinates count').to.equal(expected) + }) +} + +const getColumnIndices = ($panel) => { + const headerCells = [ + ...$panel[0].querySelectorAll( + '[data-test="dhis2-uicore-datatablecellhead"]' + ), + ] + const scenarioCodeIndex = headerCells.findIndex((cell) => + cell.textContent + .toLowerCase() + .includes(SCENARIO_CODE_COLUMN.toLowerCase()) + ) + const typeIndex = headerCells.findIndex( + (cell) => cell.textContent.trim().toLowerCase() === 'type' + ) + expect(scenarioCodeIndex, 'scenario code column found').to.be.at.least(0) + expect(typeIndex, 'type column found').to.be.at.least(0) + return { scenarioCodeIndex, typeIndex } +} + +const readRenderedRows = ($panel, { scenarioCodeIndex, typeIndex }) => + [ + ...$panel[0].querySelectorAll( + '[data-test="dhis2-uicore-tablebody"] [data-test="dhis2-uicore-datatablerow"]' + ), + ].map((row) => { + const cells = row.querySelectorAll('td') + return { + scenarioCode: cells[scenarioCodeIndex]?.textContent.trim(), + hasPoint: cells[typeIndex]?.textContent.trim() === 'Point', + } + }) + +// The table body is virtualized (react-virtuoso's TableVirtuoso): only rows +// currently scrolled into view exist in the DOM, so a single querySelectorAll +// only ever sees a handful of rows. Scrolls the panel's +// [data-testid="virtuoso-scroller"] (react-virtuoso's own stable test id) +// from top to bottom in viewport-height steps, collecting every row rendered +// along the way, keyed by scenario code so revisited rows don't double count. +const MAX_SCROLL_STEPS = 300 + +// Reads the scroller and its metrics within a single then() callback - if a +// DOM element is returned across a then() boundary, Cypress re-wraps it as a +// jQuery object for the next callback, and jQuery objects don't expose +// clientHeight/scrollHeight (and .scrollTop is a method, not a number). +// +// Stops once a requested scroll doesn't actually move scrollTop any further, +// rather than comparing against scrollHeight up front - react-virtuoso can +// under-report scrollHeight before it's finished measuring row heights, +// which was ending the walk after a single step. +// +// Steps by half a viewport (not a full one), and after each scroll waits for +// two consecutive reads of the rendered rows to come back identical - +// react-virtuoso mounts newly-visible rows asynchronously (a tick after the +// scroll position itself changes), so reading right after a full-viewport +// jump could land mid-render and skip whatever hadn't mounted yet. +const rowsSignature = (rows) => + rows + .map(({ scenarioCode, hasPoint }) => `${scenarioCode}:${hasPoint}`) + .join('|') + +const waitForRenderedRowsToSettle = () => { + let lastSignature = null + let stableCount = 0 + + return cy + .getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .should(($panel) => { + const signature = rowsSignature( + readRenderedRows($panel, getColumnIndices($panel)) + ) + if (signature === lastSignature) { + stableCount++ + } else { + stableCount = 0 + lastSignature = signature + } + expect(stableCount, 'rendered rows settled').to.be.at.least(1) + }) +} + +const collectAllTableRows = ( + stepsLeft = MAX_SCROLL_STEPS, + seen = new Map(), + lastScrollTop = -1 +) => + waitForRenderedRowsToSettle().then(($panel) => { + const indices = getColumnIndices($panel) + readRenderedRows($panel, indices).forEach((row) => { + if (row.scenarioCode) { + seen.set(row.scenarioCode, row.hasPoint) + } + }) + + const scroller = $panel[0].querySelector( + '[data-testid="virtuoso-scroller"]' + ) + const scrollTop = scroller.scrollTop + + if (scrollTop === lastScrollTop || stepsLeft <= 0) { + return seen + } + + cy.wrap(scroller).scrollTo(0, scrollTop + scroller.clientHeight / 2, { + ensureScrollable: false, + }) + return cy.then(() => + collectAllTableRows(stepsLeft - 1, seen, scrollTop) + ) + }) + +// Reads the data table (opened by checkCombo() just before this runs) and +// asserts the exact set of scenario codes with no Point geometry matches +// expectation - not just how many, so a bug that resolves the right NUMBER +// of events but the wrong ONES still fails. +const assertTableMatchesExpectation = ({ total, withoutCoordsCodes }) => { + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .find('[data-testid="virtuoso-scroller"]') + .scrollTo('top', { ensureScrollable: false }) + + collectAllTableRows().then((seen) => { + expect(seen.size, 'total table rows').to.equal(total) + + const actualWithoutCoordsCodes = [...seen] + .filter(([, hasPoint]) => !hasPoint) + .map(([scenarioCode]) => scenarioCode) + + expect( + actualWithoutCoordsCodes.sort(), + 'scenario codes with no point geometry' + ).to.deep.equal([...withoutCoordsCodes].sort()) + }) +} + +// dhis2-uicore-layer is @dhis2/ui's generic Layer wrapper - used by +// Popover, but also by Modal (the edit dialog itself), which leaves its +// own instance in the DOM hidden (display: none) rather than unmounting it +// when closed. So an open popover has to be told apart from that hidden +// leftover by visibility, not just presence. +const VISIBLE_LAYER_SELECTOR = '[data-test="dhis2-uicore-layer"]:visible' + +// Dismisses an open popover without toggling anything - re-clicking its +// trigger button doesn't work here, since the popover's own layer covers +// the whole viewport, sitting above the trigger while open. Established +// pattern (see thematic_layer.js): click the outer layer wrapper's +// topLeft corner, away from wherever the popover's own content is +// positioned. .last() targets the most recently opened one, in case a +// stray one is left over elsewhere. +const dismissPopover = () => + cy.get(VISIBLE_LAYER_SELECTOR).last().click('topLeft') + +// If a previous combo's attempt failed mid-selection, the edit dialog can +// be left open with a stale, half-applied selection, and the field +// dropdown it was mid-click on can still be open too - layered above the +// modal, covering its Cancel button. Clears that nested popover first (a +// stray dismissPopover() click on the modal's own layer is a harmless +// no-op, since Modals don't close on an outside/backdrop click by design), +// then uses the modal's own Cancel button for a clean, predictable +// starting point rather than trying to resume whatever state it's in. +const ensureEditDialogClosed = () => { + cy.get('body').then(($body) => { + if ($body.find(VISIBLE_LAYER_SELECTOR).length > 0) { + dismissPopover() + } + }) + cy.get('body').then(($body) => { + if ($body.find('[data-test="layeredit"]').length > 0) { + cy.getByDataTest('dhis2-uicore-modalactions') + .contains('Cancel') + .click() + } + }) + cy.getByDataTest('layeredit').should('not.exist') +} + +// EventDataItemsProvider.jsx wraps the whole edit dialog and fires these +// two queries as soon as it mounts (see useProgramStageDataElements.js and +// useProgramTrackedEntityAttributes.js) - CoordinateField.jsx's option +// list depends on both, and doesn't stop changing until they resolve. +// Waiting on them directly (rather than polling the DOM for "it looks +// stable now") closes the race at its source instead of narrowing it. +// intercept matches against the raw request URL, so query values need +// encoding to match too - established pattern, see manageLayerSources.cy.js +// and usersettings.cy.js. +const interceptCoordinateFieldDataItems = () => { + cy.intercept( + 'GET', + `**/api/*/programStages/*?fields=${encodeURIComponent( + 'programStageDataElements' + )}*` + ).as('programStageDataElements') + cy.intercept( + 'GET', + `**/api/*/programs/*?fields=${encodeURIComponent( + 'trackedEntityType,programTrackedEntityAttributes' + )}*` + ).as('programTrackedEntityAttributes') +} + +const applyCombo = (Layer, combo) => { + ensureEditDialogClosed() + interceptCoordinateFieldDataItems() + cy.getByDataTest('layer-edit-button').click() + cy.wait( + ['@programStageDataElements', '@programTrackedEntityAttributes'], + EXTENDED_TIMEOUT + ) + Layer.selectTab('Data') + Layer.selectCoordinate(labelOf(combo.mainField)) + Layer.selectFallbackCoordinate(labelOf(combo.fallbackField)) + Layer.updateMap() + Layer.validateDialogClosed(true) + cy.waitForMap() +} + +const checkCombo = (program, hasTei, combo) => { + const expectation = resolveComboExpectation({ + program, + hasTei, + mainField: combo.mainField, + fallbackField: combo.fallbackField, + }) + assertEventsWithoutCoordinatesCount(expectation.withoutCoordsCodes.size) + + // Opened/closed around just this check, not left open during applyCombo: + // BottomPanel's ResizeObserver keeps reflowing the page while the table + // is open, which was detaching the edit dialog's field popper mid-click. + openDataTable() + assertTableMatchesExpectation(expectation) + closeDataTable() +} + +// Maximizes the panel (BottomPanel.jsx clamps this to the available window +// height) instead of the default 300px, so far fewer scroll steps are +// needed to see every row - simulating the drag-to-resize handle itself +// isn't practical from Cypress. window.store is only exposed when +// window.Cypress is set (see src/store/index.js). +const maximizeDataTableHeight = () => + cy.window().its('store').invoke('dispatch', { + type: 'DATA_TABLE_RESIZE', + height: 10000, + }) + +// Tolerates the table already being open (e.g. left open by a failed/ +// retried test) instead of assuming "Show data table" is always the menu's +// current label. Then waits out DataTable.jsx's own loading cover +// (dhis2-uicore-componentcover, shown while the event layer's "extended"/ +// full-geometry data is still loading) - the panel and even some rows can +// render before that finishes, which under-counted rows before this wait. +const openDataTable = () => { + cy.getByDataTest('moremenubutton').first().click() + cy.getByDataTest('more-menu').then(($menu) => { + if (/hide data table/i.test($menu.text())) { + dismissPopover() + } else { + cy.wrap($menu).find('li').contains('Show data table').click() + } + }) + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT).should('be.visible') + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .find('[data-test="dhis2-uicore-componentcover"]') + .should('not.exist') + cy.getByDataTest('dhis2-uicore-datatablerow', EXTENDED_TIMEOUT).should( + 'have.length.greaterThan', + 0 + ) + maximizeDataTableHeight() +} + +const closeDataTable = () => { + cy.getByDataTest('moremenubutton').first().click() + cy.getByDataTest('more-menu').then(($menu) => { + if (/show data table/i.test($menu.text())) { + dismissPopover() + } else { + cy.wrap($menu).find('li').contains('Hide data table').click() + } + }) + cy.getByDataTest('bottom-panel').should('not.exist') +} + +describe('Event layer coordinate/fallback scenario matrix', () => { + before(() => { + cy.fixture('eventCoordinateFallbackScenarios.json').then((data) => { + scenarios = data + }) + cy.fixture('eventCoordinateFallbackFieldIds.json').then((data) => { + fieldIds = data + }) + }) + + // testIsolation: false - the layer added in before() must stay on + // screen across every it() below; default test isolation resets to a + // blank page between tests and would remove it. Only supported as a + // suite/test config override, not via Cypress.config() at runtime. + describe( + 'Maps Test Coordinates (Event) - no registration', + { testIsolation: false }, + () => { + const combos = buildCombos(EVENT_PROGRAM_MAIN_FIELDS) + + before(() => { + cy.visit('/') + + const Layer = new EventLayer() + Layer.openDialog('Events') + .selectProgram('Maps Test Coordinates (Event)') + .validateStage('Maps Test Stage (Event)') + .selectTab('Period') + .selectStartEndDates() + .typeStartDate(PERIOD_START_DATE) + .typeEndDate(PERIOD_END_DATE) + .selectTab('Org Units') + .unselectOu('Sierra Leone') + .selectOu(TEST_ORG_UNITS[0]) + .selectOu(TEST_ORG_UNITS[1]) + .selectTab('Style') + .selectCountEventsWithoutCoordinates() + .selectLabelField('Maps Test Scenario Code DE') + + Layer.addToMap() + Layer.validateDialogClosed(true) + cy.waitForMap() + }) + + combos.forEach((combo) => { + it(comboTitle(combo), () => { + const Layer = new EventLayer() + applyCombo(Layer, combo) + checkCombo('event', false, combo) + }) + }) + } + ) + + // testIsolation: false - see the event program's describe() above. + describe( + 'Maps Test Coordinates (Tracker) - with registration', + { testIsolation: false }, + () => { + const combos = buildCombos(TRACKER_PROGRAM_MAIN_FIELDS) + + before(() => { + cy.visit('/') + + const Layer = new EventLayer() + Layer.openDialog('Events') + .selectProgram('Maps Test Coordinates (Tracker)') + .validateStage('Maps Test Stage (Tracker)') + .selectTab('Period') + .selectStartEndDates() + .typeStartDate(PERIOD_START_DATE) + .typeEndDate(PERIOD_END_DATE) + .selectTab('Org Units') + .unselectOu('Sierra Leone') + .selectOu(TEST_ORG_UNITS[0]) + .selectOu(TEST_ORG_UNITS[1]) + .selectTab('Style') + .selectCountEventsWithoutCoordinates() + .selectLabelField('Maps Test Scenario Code DE') + + Layer.addToMap() + Layer.validateDialogClosed(true) + cy.waitForMap() + }) + + combos.forEach((combo) => { + it(comboTitle(combo), () => { + const Layer = new EventLayer() + applyCombo(Layer, combo) + checkCombo('tracker', true, combo) + }) + }) + } + ) +}) diff --git a/cypress/support/cypressFiles.json b/cypress/support/cypressFiles.json index c349aedc3d..0f04446e47 100644 --- a/cypress/support/cypressFiles.json +++ b/cypress/support/cypressFiles.json @@ -107,5 +107,9 @@ "cypress/integration/layers/trackedentitylayer.cy.js": { "include": true, "duration": 38 + }, + "cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js": { + "include": false, + "note": "yarn cypress run --spec cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js" } } diff --git a/cypress/support/requests.js b/cypress/support/requests.js index e72359921f..be030960c1 100644 --- a/cypress/support/requests.js +++ b/cypress/support/requests.js @@ -64,7 +64,7 @@ const requests = { }, getCachedDataProvider_SystemSettings: { method: 'GET', - url: '**/systemSettings?key=keyAnalysisRelativePeriod,keyHideDailyPeriods,keyHideWeeklyPeriods,keyHideBiWeeklyPeriods,keyHideMonthlyPeriods,keyHideBiMonthlyPeriods,keyDefaultBaseMap,keyBingMapsApiKey,keyAzureMapsApiKey', + url: '**/systemSettings?key=keyAnalysisRelativePeriod,keyHideDailyPeriods,keyHideWeeklyPeriods,keyHideBiWeeklyPeriods,keyHideMonthlyPeriods,keyHideBiMonthlyPeriods,keyDefaultBaseMap,orgUnitCentroidsInEventsAnalytics,keyBingMapsApiKey,keyAzureMapsApiKey', }, getCachedDataProvider_ExternalMapLayers: { method: 'GET', diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..f7d0209d82 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-01T12:42:29.454Z\n" -"PO-Revision-Date: 2026-09-01T12:42:29.454Z\n" +"POT-Creation-Date: 2026-09-21T14:42:04.085Z\n" +"PO-Revision-Date: 2026-09-21T14:42:04.085Z\n" msgid "2020" msgstr "2020" @@ -83,6 +83,18 @@ msgstr "Yes" msgid "No" msgstr "No" +msgid "Select a program to see additional coordinate options" +msgstr "Select a program to see additional coordinate options" + +msgid "Event > enrollment > tracked entity > org unit coordinate" +msgstr "Event > enrollment > tracked entity > org unit coordinate" + +msgid "Event > org unit coordinate" +msgstr "Event > org unit coordinate" + +msgid "Select a program stage to see additional coordinate options" +msgstr "Select a program stage to see additional coordinate options" + msgid "None" msgstr "None" @@ -101,18 +113,6 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Select a program stage to see additional coordinate options" -msgstr "Select a program stage to see additional coordinate options" - -msgid "Enrollment > event > tracked entity > org unit coordinate" -msgstr "Enrollment > event > tracked entity > org unit coordinate" - -msgid "Event > org unit coordinate" -msgstr "Event > org unit coordinate" - -msgid "Select a program to see additional coordinate options" -msgstr "Select a program to see additional coordinate options" - msgid "Fallback coordinate field" msgstr "Fallback coordinate field" @@ -125,6 +125,9 @@ msgstr "Include unclassified events" msgid "Include events with no data" msgstr "Include events with no data" +msgid "Coordinate source" +msgstr "Coordinate source" + msgid "Previously selected value not available in list: {{id}}" msgstr "Previously selected value not available in list: {{id}}" @@ -710,6 +713,9 @@ msgstr "Duplicate layer" msgid "Remove layer" msgstr "Remove layer" +msgid "fallback" +msgstr "fallback" + msgid "Filters" msgstr "Filters" @@ -1834,6 +1840,9 @@ msgstr "The event filter is not supported" msgid "An unknown error occurred while reading layer data" msgstr "An unknown error occurred while reading layer data" +msgid "The fallback coordinate field is not supported by this server version" +msgstr "The fallback coordinate field is not supported by this server version" + msgid "Displaying first {{pageSize}} events out of {{total}}" msgstr "Displaying first {{pageSize}} events out of {{total}}" diff --git a/src/actions/__tests__/layerEdit.spec.js b/src/actions/__tests__/layerEdit.spec.js index 2ae909d06d..48e8853669 100644 --- a/src/actions/__tests__/layerEdit.spec.js +++ b/src/actions/__tests__/layerEdit.spec.js @@ -19,7 +19,7 @@ import { setLegendIsolated, setEventStatus, setEventCoordinateField, - setFallbackCoordinateField, + setEventCoordinateFieldFallback, setEventClustering, setCountFeaturesWithoutCoordinates, setCountEventsOutsideOrgUnits, @@ -66,6 +66,7 @@ import { setEarthEnginePeriod, setFeatureStyle, setLabelDataItem, + setHasTrackedEntityType, } from '../layerEdit.js' describe('layerEdit simple action creators', () => { @@ -185,9 +186,9 @@ describe('layerEdit simple action creators', () => { rest: { fieldId: 'field1', fieldType: 'COORDINATE' }, }, { - creator: setFallbackCoordinateField, + creator: setEventCoordinateFieldFallback, args: ['field1'], - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, rest: { fieldId: 'field1' }, }, { @@ -460,6 +461,12 @@ describe('layerEdit simple action creators', () => { type: types.LAYER_EDIT_LABEL_DATA_ITEM_ID_SET, rest: { item: { id: 'de1' } }, }, + { + creator: setHasTrackedEntityType, + args: [true], + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + rest: { value: true }, + }, ])( '$creator.name creates the expected action', ({ creator, args, type, rest }) => { diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 988c73484e..070c853d9d 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -75,6 +75,17 @@ export const setBooleanStyle = (value, color) => ({ color, }) +export const setGeometrySourceStyle = (value, color) => ({ + type: types.LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET, + value, + color, +}) + +export const setHasTrackedEntityType = (value) => ({ + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value, +}) + // Set thematic map type (choropleth, bubble map) export const setThematicMapType = (type) => ({ type: types.LAYER_EDIT_THEMATIC_MAP_TYPE_SET, @@ -116,9 +127,10 @@ export const setEventCoordinateField = (fieldId, fieldType) => ({ }) // Set fallback coordinate field -export const setFallbackCoordinateField = (fieldId) => ({ - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, +export const setEventCoordinateFieldFallback = (fieldId, fieldType) => ({ + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId, + fieldType, }) // Set if event clustering should be used (event) diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index 4cf158e9af..8a8a51e604 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -10,9 +10,60 @@ import { EVENT_COORDINATE_CASCADING, NONE, } from '../../constants/layers.js' +import { + coordinateValueTypes, + ouValueTypes, +} from '../../constants/valueTypes.js' +import { + serverSupportsGeometrySource, + serverSupportsOrgUnitCoordinateField, +} from '../../util/versionToggle.js' import { SelectField } from '../core/index.js' import { useEventDataItems } from './EventDataItemsProvider.jsx' +const getIncludeTypes = (isFallback, serverVersion) => { + const includeTypes = [...coordinateValueTypes] + + if (isFallback) { + // VERSION-TOGGLE: eventCoordinateFieldFallback pointed at a custom + // ORGANISATION_UNIT field crashes pre-2.44 - see util/versionToggle.js + if (serverSupportsGeometrySource(serverVersion)) { + includeTypes.push(...ouValueTypes) + } + return includeTypes + } + + // VERSION-TOGGLE: ORGANISATION_UNIT isn't a valid coordinate field + // pre-2.40.8/2.41.4/2.42 - see util/versionToggle.js + if (serverSupportsOrgUnitCoordinateField(serverVersion)) { + includeTypes.push(...ouValueTypes) + } + + return includeTypes +} + +const getHelpText = ({ program, programStage, value, trackedEntityType }) => { + if (!program) { + return i18n.t('Select a program to see additional coordinate options') + } + + if (value === EVENT_COORDINATE_CASCADING) { + return trackedEntityType?.id + ? i18n.t( + 'Event > enrollment > tracked entity > org unit coordinate' + ) + : i18n.t('Event > org unit coordinate') + } + + if (!programStage && trackedEntityType?.id) { + return i18n.t( + 'Select a program stage to see additional coordinate options' + ) + } + + return null +} + const CoordinateField = ({ value, type, @@ -21,22 +72,16 @@ const CoordinateField = ({ eventCoordinateField, onChange, className, + dataTest = 'coordinatefield', }) => { const { serverVersion } = useConfig() - - // VERSION-TOGGLE - // https://dhis2.atlassian.net/browse/DHIS2-19010 and: - // - [2.40.8] https://github.com/dhis2/dhis2-core/commit/f2286a5aa70b2957bd24925776e9394cd67d44c1 - // - [2.41.4] https://github.com/dhis2/dhis2-core/commit/19f29f27385cfae1c7fac234439f49987ec2abe4 - // - [2.42.0] https://github.com/dhis2/dhis2-core/commit/e5b29f4f1dbee791be9e6befb8a304151a1661c9 - const includeTypes = ['COORDINATE'] - if ( - (serverVersion.minor === 40 && serverVersion.patch >= 8) || - (serverVersion.minor === 41 && serverVersion.patch >= 4) || - serverVersion.minor >= 42 - ) { - includeTypes.push('ORGANISATION_UNIT') - } + const isFallback = !!eventCoordinateField + // true sentinel means the main field is at its implicit default + const mainFieldId = + eventCoordinateField === true + ? EVENT_COORDINATE_DEFAULT + : eventCoordinateField + const includeTypes = getIncludeTypes(isFallback, serverVersion) const { eventDataItems, @@ -44,10 +89,12 @@ const CoordinateField = ({ loading: itemsLoading, } = useEventDataItems({ includeTypes }) - const defaultValue = eventCoordinateField ? NONE : EVENT_COORDINATE_DEFAULT + const defaultValue = useMemo( + () => (eventCoordinateField ? NONE : EVENT_COORDINATE_DEFAULT), + [eventCoordinateField] + ) const fields = useMemo(() => { - const isFallback = !!eventCoordinateField const fields = [] if (isFallback) { @@ -70,10 +117,6 @@ const CoordinateField = ({ name: i18n.t('Event location'), }) - if (!trackedEntityType) { - return fields - } - if (trackedEntityType?.id) { fields.push({ id: EVENT_COORDINATE_ENROLLMENT, @@ -85,40 +128,19 @@ const CoordinateField = ({ }) } - if (eventDataItems) { + if (trackedEntityType && eventDataItems) { fields.push(...eventDataItems) } - if (isFallback) { - fields.push({ - id: EVENT_COORDINATE_ORG_UNIT, - name: i18n.t('Organisation unit location'), - }) - } + return isFallback ? fields.filter((f) => f.id !== mainFieldId) : fields + }, [trackedEntityType, eventDataItems, mainFieldId, isFallback]) - return eventCoordinateField - ? fields.filter((f) => f.id !== eventCoordinateField) - : fields - }, [trackedEntityType, eventDataItems, eventCoordinateField]) - - let helpText = null - if (program) { - if (!programStage && trackedEntityType) { - helpText = i18n.t( - 'Select a program stage to see additional coordinate options' - ) - } else if (value === EVENT_COORDINATE_CASCADING) { - helpText = trackedEntityType - ? i18n.t( - 'Enrollment > event > tracked entity > org unit coordinate' - ) - : i18n.t('Event > org unit coordinate') - } - } else { - helpText = i18n.t( - 'Select a program to see additional coordinate options' - ) - } + const helpText = getHelpText({ + program, + programStage, + value, + trackedEntityType, + }) // Initiate type when editing saved layer useEffect(() => { @@ -135,7 +157,9 @@ const CoordinateField = ({ if ( trackedEntityType && eventDataItems && - !fields.find((f) => f.id === value) + !fields.some((f) => f.id === value) && + value !== defaultValue && + fields.length > 0 ) { onChange(defaultValue, defaultValue) } @@ -156,7 +180,7 @@ const CoordinateField = ({ : i18n.t('Coordinate field') } items={fields} - value={fields.find((f) => f.id === value) ? value : null} + value={fields.some((f) => f.id === value) ? value : null} loading={ !!program && value !== EVENT_COORDINATE_DEFAULT && itemsLoading } @@ -165,7 +189,7 @@ const CoordinateField = ({ onChange(field.id, field.valueType || field.id) } className={className} - dataTest="coordinatefield" + dataTest={dataTest} /> ) } @@ -173,7 +197,11 @@ const CoordinateField = ({ CoordinateField.propTypes = { onChange: PropTypes.func.isRequired, className: PropTypes.string, - eventCoordinateField: PropTypes.string, + dataTest: PropTypes.string, + eventCoordinateField: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), program: PropTypes.object, programStage: PropTypes.object, type: PropTypes.string, diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index dc75e11d3e..59039702a0 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -6,6 +6,7 @@ import { setNoDataLegend, setUnclassifiedLegend, } from '../../actions/layerEdit.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { numberValueTypes, booleanValueTypes, @@ -15,6 +16,7 @@ import NoDataLegend from '../edit/shared/NoDataLegend.jsx' import UnclassifiedLegend from '../edit/shared/UnclassifiedLegend.jsx' import OptionSetStyle from '../optionSet/OptionSetStyle.jsx' import BooleanStyle from './BooleanStyle.jsx' +import GeometrySourceStyle from './GeometrySourceStyle.jsx' import styles from './styles/DataItemStyle.module.css' const DataItemStyle = ({ dataItem, style }) => { @@ -28,6 +30,14 @@ const DataItemStyle = ({ dataItem, style }) => { return null } + if (dataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { + return ( +
+ +
+ ) + } + const { valueType, optionSet } = dataItem const hasClassification = numberValueTypes.includes(valueType) || diff --git a/src/components/dataItem/EventDataItemsProvider.jsx b/src/components/dataItem/EventDataItemsProvider.jsx index 5720d7b808..c967bfbd59 100644 --- a/src/components/dataItem/EventDataItemsProvider.jsx +++ b/src/components/dataItem/EventDataItemsProvider.jsx @@ -1,5 +1,7 @@ import PropTypes from 'prop-types' -import React, { createContext, useContext, useMemo } from 'react' +import React, { createContext, useContext, useEffect, useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { setHasTrackedEntityType } from '../../actions/layerEdit.js' import { useProgramStageDataElements } from '../../hooks/useProgramStageDataElements.js' import { useProgramTrackedEntityAttributes } from '../../hooks/useProgramTrackedEntityAttributes.js' import { combineDataItems } from '../../util/analytics.js' @@ -9,8 +11,10 @@ const EventDataItemsCtx = createContext(null) // Fetches program stage data elements and program tracked entity attributes // once, and shares them with every useEventDataItems() call within — avoids // duplicate network requests when several components need the same -// program/programStage's data items, each with their own type filter. +// program/programStage's data items, each with their own type filter. Also +// mirrors hasTrackedEntityType into redux for the reducer to read. const EventDataItemsProvider = ({ programId, programStageId, children }) => { + const dispatch = useDispatch() const { dataElements, loading: dataElementsLoading } = useProgramStageDataElements({ programStageId }) const { @@ -19,6 +23,12 @@ const EventDataItemsProvider = ({ programId, programStageId, children }) => { loading: attributesLoading, } = useProgramTrackedEntityAttributes({ programId }) + useEffect(() => { + if (trackedEntityType !== null) { + dispatch(setHasTrackedEntityType(!!trackedEntityType?.id)) + } + }, [trackedEntityType, dispatch]) + const value = useMemo( () => ({ dataElements, diff --git a/src/components/dataItem/GeometrySourceStyle.jsx b/src/components/dataItem/GeometrySourceStyle.jsx new file mode 100644 index 0000000000..3829e85f71 --- /dev/null +++ b/src/components/dataItem/GeometrySourceStyle.jsx @@ -0,0 +1,75 @@ +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { setGeometrySourceStyle } from '../../actions/layerEdit.js' +import { COORDINATE_FIELD_NAMES } from '../../constants/layers.js' +import { + getDefaultGeometrySourceColor, + getPossibleGeometrySources, +} from '../../util/coordinatesName.js' +import OptionStyle from '../optionSet/OptionStyle.jsx' +import { useEventDataItems } from './EventDataItemsProvider.jsx' + +const style = { + marginTop: 20, +} + +const GeometrySourceStyle = () => { + const dispatch = useDispatch() + const styleDataItem = useSelector((state) => state.layerEdit.styleDataItem) + const eventCoordinateField = useSelector( + (state) => state.layerEdit.eventCoordinateField + ) + const eventCoordinateFieldFallback = useSelector( + (state) => state.layerEdit.eventCoordinateFieldFallback + ) + const { eventDataItems, trackedEntityType } = useEventDataItems({ + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + + const hasTei = !!trackedEntityType?.id + + const sources = getPossibleGeometrySources( + eventCoordinateField, + eventCoordinateFieldFallback, + hasTei + ) + + const values = styleDataItem?.values + + // Wait for event data items to load before rendering, so DE/TEA UIDs + // are never shown raw (trackedEntityType also drives sources via hasTei) + if (eventDataItems === null || !values) { + return null + } + + const resolveLabel = (sourceId) => { + if (COORDINATE_FIELD_NAMES[sourceId]) { + return COORDINATE_FIELD_NAMES[sourceId] + } + const item = eventDataItems?.find((i) => i.id === sourceId) + return item?.name ?? sourceId + } + + return ( +
+ {sources.map((sourceId) => ( + + dispatch(setGeometrySourceStyle(sourceId, color)) + } + /> + ))} +
+ ) +} + +export default GeometrySourceStyle diff --git a/src/components/dataItem/StyleByDataItem.jsx b/src/components/dataItem/StyleByDataItem.jsx index 0281aec6c0..5102d6d67c 100644 --- a/src/components/dataItem/StyleByDataItem.jsx +++ b/src/components/dataItem/StyleByDataItem.jsx @@ -1,9 +1,12 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Help } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { setStyleDataItem } from '../../actions/layerEdit.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' +import { serverSupportsGeometrySource } from '../../util/versionToggle.js' import { SelectField } from '../core/index.js' import DataItemStyle from './DataItemStyle.jsx' import { useEventDataItems } from './EventDataItemsProvider.jsx' @@ -15,10 +18,16 @@ const excludeTypes = [ 'COORDINATE', ] +const GEOMETRY_SOURCE_ITEM = { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: i18n.t('Coordinate source'), +} + // Style by data item is used by event layer, and can be reused for TEI layer in the future. // Displays a select field with data items that support styling. // Styling options are shown when a data item is selected. const StyleByDataItem = ({ error }) => { + const { serverVersion } = useConfig() const styleDataItem = useSelector((state) => state.layerEdit.styleDataItem) const dispatch = useDispatch() const { eventDataItems } = useEventDataItems({ excludeTypes }) @@ -32,7 +41,14 @@ const StyleByDataItem = ({ error }) => { return null } - const dataItems = [ITEM_NONE, ...eventDataItems] + // VERSION-TOGGLE: geometrySource only exists on 2.44+ - see util/versionToggle.js. + const dataItems = [ + ITEM_NONE, + ...(serverSupportsGeometrySource(serverVersion) + ? [GEOMETRY_SOURCE_ITEM] + : []), + ...eventDataItems, + ] const internalError = styleDataItem && !dataItems.find((item) => item.id === styleDataItem.id) diff --git a/src/components/dataItem/__tests__/CoordinateField.spec.jsx b/src/components/dataItem/__tests__/CoordinateField.spec.jsx new file mode 100644 index 0000000000..4ec927b3f4 --- /dev/null +++ b/src/components/dataItem/__tests__/CoordinateField.spec.jsx @@ -0,0 +1,143 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import CoordinateField from '../CoordinateField.jsx' + +const mockUseConfig = jest.fn() +jest.mock('@dhis2/app-runtime', () => ({ + useConfig: (...args) => mockUseConfig(...args), +})) + +const mockUseEventDataItems = jest.fn() +jest.mock('../EventDataItemsProvider.jsx', () => ({ + useEventDataItems: (...args) => mockUseEventDataItems(...args), +})) + +const ouTypeItem = { + id: 'ouFieldId1', + name: 'OU Field', + valueType: 'ORGANISATION_UNIT', +} + +// OU-type fallback (2.44+) is a separate, later gate than OU-type main fields (2.42+). +describe('CoordinateField', () => { + beforeEach(() => { + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: null, + loading: false, + }) + }) + + it('includes ORGANISATION_UNIT in the main field type filter regardless of version', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 43 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + }) + + it('excludes ORGANISATION_UNIT from the fallback field type filter pre-2.44', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 43 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE'], + }) + }) + + it('includes ORGANISATION_UNIT in the fallback field type filter on 2.44+', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + }) + + it('excludes "Event location" from the fallback options when the main field is left at its implicit default', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + + render( + + ) + + fireEvent.click(screen.getByTestId('dhis2-uicore-select-input')) + + const labels = screen + .getAllByTestId('dhis2-uicore-singleselectoption') + .map((el) => el.textContent) + expect(labels).not.toContain('Event location') + }) + + it('renders the OU-type field as a selectable option when included', async () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + // Custom items only appear once a tracked entity type exists. + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: { id: 'tet1' }, + loading: false, + }) + + render( + + ) + + fireEvent.click(screen.getByTestId('dhis2-uicore-select-input')) + + const labels = screen + .getAllByTestId('dhis2-uicore-singleselectoption') + .map((el) => el.textContent) + expect(labels).toContain('OU Field') + }) + + it('shows the org-unit-only cascade help text for a program without registration', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + // trackedEntityType resolves to {} (not null) for WITHOUT_REGISTRATION programs. + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: {}, + loading: false, + }) + + render( + + ) + + expect(screen.getByText('Event > org unit coordinate')).toBeTruthy() + expect( + screen.queryByText( + 'Event > enrollment > tracked entity > org unit coordinate' + ) + ).toBeNull() + }) +}) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3d92479c39..43539249fe 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -215,6 +215,10 @@ const Table = ({ availableWidth }) => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + const measuredColumnWidths = [] for (const cell of headerRowRef.current.cells) { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index aa8d588656..8e098e1a79 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -289,6 +289,80 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + it.each([ + { + desc: 'built-in source, falls back to COORDINATE_FIELD_NAMES', + geometrySourceNames: undefined, + geometrySource: 'ougeometry', + expectedValue: 'Organisation unit location', + }, + { + desc: 'custom source, resolved via layer.geometrySourceNames', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + abcDataElementUid1: 'My custom field', + }, + geometrySource: 'abcDataElementUid1', + expectedValue: 'My custom field', + }, + ])( + 'includes a geometrySource column with a resolved name: $desc', + ({ geometrySourceNames, geometrySource, expectedValue }) => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + ...(geometrySourceNames && { geometrySourceNames }), + headers: [ + { + name: 'geometrySource', + column: 'Geometry source', + valueType: 'TEXT', + }, + ], + data: [ + { + properties: { + id: 'a9712323629', + type: 'Point', + ouname: 'Lumley Hospital', + eventdate: '2023-05-15 00:00:00.0', + geometrySource, + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Geometry source', + dataKey: 'geometrySource', + type: 'string', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: expectedValue, + dataKey: 'geometrySource', + }) + ) + } + ) + test('treats NUMBER header with optionSet as string type', () => { const store = { aggregations: {} } const layer = { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8f..8ed8c1c30e 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -8,6 +8,8 @@ import { EARTH_ENGINE_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + EVENT_COORDINATE_GEOMETRY_SOURCE, + COORDINATE_FIELD_NAMES, } from '../../constants/layers.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' @@ -125,7 +127,10 @@ const getEventHeaders = ({ } const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) + .filter( + ({ name }) => + isValidUid(name) || name === EVENT_COORDINATE_GEOMETRY_SOURCE + ) .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ name, dataKey, @@ -214,6 +219,7 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { dataFilters, headers: layerHeaders, serverCluster, + geometrySourceNames, } = layer || EMPTY_LAYER const dataWithAggregations = useMemo(() => { @@ -238,14 +244,31 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { })) } + const names = geometrySourceNames || COORDINATE_FIELD_NAMES + return allData .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - index, - })) - }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) + .map((d, index) => { + const rawSource = + d.properties?.[EVENT_COORDINATE_GEOMETRY_SOURCE] + return { + ...(d.properties || d), + ...(rawSource != null && { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: + names[rawSource] ?? rawSource, + }), + ...aggregations[d.id], + index, + } + }) + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerType, + geometrySourceNames, + ]) const headers = useMemo(() => { if (errorCode.current) { diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 5302dd24e5..b597b61569 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -13,7 +13,7 @@ import { setEventClustering, setEventPointColor, setEventPointRadius, - // setFallbackCoordinateField, + setEventCoordinateFieldFallback, setPeriods, setPeriodType, setStartDate, @@ -80,6 +80,8 @@ const EventDialog = ({ eventPointColor, eventPointRadius, eventStatus, + eventCoordinateFieldFallback = 'none', + eventCoordinateFieldFallbackType = 'none', filters, legendIsolated, legendSet, @@ -318,16 +320,25 @@ const EventDialog = ({ data-test="eventdialog-coordinatefield" /> - {/* eventCoordinateField && ( + dispatch( + setEventCoordinateFieldFallback( + fieldId, + fieldType + ) + ) + } className={styles.select} + dataTest="eventcoordinatefieldfallback" /> - ) */} @@ -558,11 +569,12 @@ EventDialog.propTypes = { endDate: PropTypes.string, eventClustering: PropTypes.bool, eventCoordinateField: PropTypes.string, + eventCoordinateFieldFallback: PropTypes.string, + eventCoordinateFieldFallbackType: PropTypes.string, eventCoordinateFieldType: PropTypes.string, eventPointColor: PropTypes.string, eventPointRadius: PropTypes.number, eventStatus: PropTypes.string, - // fallbackCoordinateField: PropTypes.string, filters: PropTypes.array, legendIsolated: PropTypes.object, legendSet: PropTypes.object, diff --git a/src/components/edit/event/LabelFieldSelect.jsx b/src/components/edit/event/LabelFieldSelect.jsx index cfa1a323f0..5b8cfa7bd5 100644 --- a/src/components/edit/event/LabelFieldSelect.jsx +++ b/src/components/edit/event/LabelFieldSelect.jsx @@ -1,13 +1,22 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { setLabelDataItem } from '../../../actions/layerEdit.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../../constants/layers.js' +import { serverSupportsGeometrySource } from '../../../util/versionToggle.js' import { useEventDataItems } from '../../dataItem/EventDataItemsProvider.jsx' import Labels from '../shared/Labels.jsx' const ITEM_NONE = { id: 'none', name: i18n.t('None') } +const GEOMETRY_SOURCE_ITEM = { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: i18n.t('Coordinate source'), +} + const LabelFieldSelect = () => { + const { serverVersion } = useConfig() const labelDataItemId = useSelector( (state) => state.layerEdit.labelDataItem?.id ) @@ -18,7 +27,14 @@ const LabelFieldSelect = () => { return null } - const items = [ITEM_NONE, ...eventDataItems] + // VERSION-TOGGLE: geometrySource only exists on 2.44+ - see util/versionToggle.js. + const items = [ + ITEM_NONE, + ...(serverSupportsGeometrySource(serverVersion) + ? [GEOMETRY_SOURCE_ITEM] + : []), + ...eventDataItems, + ] return ( + (!!eventCoordinateFieldType && + !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldType)) || + (!!eventCoordinateFieldFallbackType && + !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldFallbackType)) + const GeometryCentroid = ({ tab, geometryCentroid, eventCoordinateFieldType, + eventCoordinateFieldFallbackType, className, setGeometryCentroid, }) => { useEffect(() => { - if (eventCoordinateFieldType) { - setGeometryCentroid( - !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldType) - ) + if (!eventCoordinateFieldType && !eventCoordinateFieldFallbackType) { + return } - }, [setGeometryCentroid, eventCoordinateFieldType]) + setGeometryCentroid( + needsGeometryCentroid( + eventCoordinateFieldType, + eventCoordinateFieldFallbackType + ) + ) + }, [ + setGeometryCentroid, + eventCoordinateFieldType, + eventCoordinateFieldFallbackType, + ]) return (
@@ -66,6 +85,7 @@ const GeometryCentroid = ({ GeometryCentroid.propTypes = { setGeometryCentroid: PropTypes.func.isRequired, className: PropTypes.string, + eventCoordinateFieldFallbackType: PropTypes.string, eventCoordinateFieldType: PropTypes.string, geometryCentroid: PropTypes.bool, tab: PropTypes.string, @@ -75,6 +95,8 @@ export default connect( ({ layerEdit }) => ({ geometryCentroid: layerEdit?.geometryCentroid, eventCoordinateFieldType: layerEdit?.eventCoordinateFieldType, + eventCoordinateFieldFallbackType: + layerEdit?.eventCoordinateFieldFallbackType, }), { setGeometryCentroid } )(GeometryCentroid) diff --git a/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx b/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx new file mode 100644 index 0000000000..8cdbcf5ba4 --- /dev/null +++ b/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx @@ -0,0 +1,32 @@ +import { needsGeometryCentroid } from '../GeometryCentroid.jsx' + +describe('needsGeometryCentroid', () => { + it('is false when both fields are built-in point-only fields', () => { + expect(needsGeometryCentroid('psigeometry', 'pigeometry')).toBe(false) + }) + + it('is false when the main field is a COORDINATE-valueType custom field', () => { + expect(needsGeometryCentroid('COORDINATE', undefined)).toBe(false) + }) + + it('is true when the main field is an org unit field', () => { + expect(needsGeometryCentroid('ougeometry', undefined)).toBe(true) + }) + + it('is true when only the fallback field needs it, main field is default', () => { + expect(needsGeometryCentroid('psigeometry', 'ougeometry')).toBe(true) + }) + + it('is true when the fallback is Cascading, since it always includes org unit', () => { + expect(needsGeometryCentroid('psigeometry', 'cascading')).toBe(true) + }) + + it('is false when the fallback is cleared (undefined type)', () => { + expect(needsGeometryCentroid('psigeometry', undefined)).toBe(false) + }) + + it('is true when either field is an unrecognized custom valueType', () => { + expect(needsGeometryCentroid('TEXT', undefined)).toBe(true) + expect(needsGeometryCentroid('psigeometry', 'TEXT')).toBe(true) + }) +}) diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index ae7603787b..9c37ddb8a7 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -82,6 +82,7 @@ const Legend = ({ description, filters, coordinateFields, + eventCoordinateFieldFallback, groups, unit, items, @@ -342,8 +343,14 @@ const Legend = ({ {Array.isArray(coordinateFields) && (
{i18n.t('Coordinate field')}
- {coordinateFields.map((coordinateField, index) => ( -
{coordinateField}
+ {coordinateFields.map((coordinateField) => ( +
+ {coordinateField} + {eventCoordinateFieldFallback && + ` (${i18n.t( + 'fallback' + )}: ${eventCoordinateFieldFallback})`} +
))}
)} @@ -399,6 +406,7 @@ Legend.propTypes = { coordinateFields: PropTypes.array, decimalPlaces: PropTypes.number, description: PropTypes.string, + eventCoordinateFieldFallback: PropTypes.string, eventsOutsideOrgUnitsCount: PropTypes.number, eventsWithoutCoordinatesCount: PropTypes.number, explanation: PropTypes.array, diff --git a/src/components/legend/__tests__/Legend.spec.jsx b/src/components/legend/__tests__/Legend.spec.jsx new file mode 100644 index 0000000000..5193f5a1e0 --- /dev/null +++ b/src/components/legend/__tests__/Legend.spec.jsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import Legend from '../Legend.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: jest.fn(() => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + })), +})) + +describe('Legend coordinate field / fallback display', () => { + it('shows only the coordinate field when there is no fallback', () => { + render() + + expect(screen.getByText('Event location')).toBeTruthy() + expect(screen.queryByText(/fallback/)).toBeNull() + }) + + it('shows the fallback alongside the coordinate field when set', () => { + render( + + ) + + expect( + screen.getByText( + 'Event location (fallback: Organisation unit location)' + ) + ).toBeTruthy() + }) +}) diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b132e09e74..0812439859 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -4,11 +4,16 @@ import React from 'react' import { EVENT_COLOR, EVENT_RADIUS, + EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_DEFAULT, LABEL_TEMPLATE_NAME_ONLY, LABEL_TEMPLATE_TOOLTIP_ONLY, } from '../../../constants/layers.js' import { getContrastColor } from '../../../util/colors.js' -import { loadEventCoordinateFieldName } from '../../../util/coordinatesName.js' +import { + loadEventCoordinateField, + resolveGeometrySourceName, +} from '../../../util/coordinatesName.js' import { getAnalyticsRequest, EVENT_PROGRAM_STAGE_DATA_ELEMENTS_QUERY, @@ -62,6 +67,7 @@ class EventLayer extends Layer { labelFontSize, labelFontWeight, labelFontStyle, + geometrySourceNames, } = this.props const analyticsEngine = Analytics.getAnalytics(engine) @@ -88,11 +94,19 @@ class EventLayer extends Layer { const noDataLabel = i18n.t('No data') const formatItemValue = (feature, dataItem) => { const v = feature.properties[dataItem.id] + // No fallback configured means the point can only be from the main field. + const geometrySource = + dataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE + ? v ?? eventCoordinateField ?? EVENT_COORDINATE_DEFAULT + : null + const resolved = geometrySource + ? resolveGeometrySourceName(geometrySource, geometrySourceNames) + : v return ( - (v != null && - v !== '' && + (resolved != null && + resolved !== '' && formatValueForDisplay({ - value: String(v), + value: String(resolved), valueType: dataItem.valueType, options: dataItem.options, keyAnalysisDigitGroupSeparator, @@ -250,8 +264,12 @@ class EventLayer extends Layer { } render() { - const { styleDataItem, nameProperty, keyAnalysisDigitGroupSeparator } = - this.props + const { + styleDataItem, + nameProperty, + keyAnalysisDigitGroupSeparator, + geometrySourceNames, + } = this.props const { popup, displayItems, eventCoordinateFieldName } = this.state return popup && displayItems ? ( @@ -262,6 +280,7 @@ class EventLayer extends Layer { keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayItems={displayItems} eventCoordinateFieldName={eventCoordinateFieldName} + geometrySourceNames={geometrySourceNames} onClose={this.onPopupClose} /> ) : null @@ -359,6 +378,7 @@ class EventLayer extends Layer { if ( styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !displayItems.some((item) => item.id === styleDataItem.id) ) { const programResponse = await engine.query( @@ -404,13 +424,15 @@ class EventLayer extends Layer { ] } - const eventCoordinateFieldName = await loadEventCoordinateFieldName({ - program, - programStage, - eventCoordinateField, - engine, - displayNameProp, - }) + const eventCoordinateFieldName = ( + await loadEventCoordinateField({ + program, + programStage, + fieldId: eventCoordinateField, + engine, + displayNameProp, + }) + )?.name this.setState({ displayItems, eventCoordinateFieldName }) } diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index a6ffca021f..ac036a51a9 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -2,6 +2,8 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../../constants/layers.js' +import { resolveGeometrySourceName } from '../../../util/coordinatesName.js' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { formatDatetime, @@ -62,6 +64,7 @@ const EventPopup = ({ keyAnalysisDigitGroupSeparator, displayItems, eventCoordinateFieldName, + geometrySourceNames, onClose, }) => { const [orgUnit, setOrgUnit] = useState() @@ -121,21 +124,31 @@ const EventPopup = ({ const { type, coordinates: coord } = feature.geometry const { dataValues = [], occurredAt } = dataEvent?.events || {} - const dataValueIndex = dataValues.findIndex( - (d) => d.dataElement === styleDataItem?.id - ) - if (dataValueIndex !== -1) { - dataValues[dataValueIndex] = { - dataElement: styleDataItem?.id, - value: feature.properties.value, + if ( + styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE + ) { + const dataValueIndex = dataValues.findIndex( + (d) => d.dataElement === styleDataItem.id + ) + if (dataValueIndex !== -1) { + dataValues[dataValueIndex] = { + dataElement: styleDataItem.id, + value: feature.properties.value, + } + } else { + dataValues.push({ + dataElement: styleDataItem.id, + value: feature.properties.value, + }) } - } else { - dataValues.push({ - dataElement: styleDataItem?.id, - value: feature.properties.value, - }) } + const geometrySource = feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] + const coordinateFieldLabel = geometrySource + ? resolveGeometrySourceName(geometrySource, geometrySourceNames) + : eventCoordinateFieldName || i18n.t('Event location') + return ( - - {eventCoordinateFieldName || - i18n.t('Event location')} - + {coordinateFieldLabel} {formatCoordinate(coord)} )} @@ -195,6 +205,7 @@ EventPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, eventCoordinateFieldName: PropTypes.string, + geometrySourceNames: PropTypes.object, keyAnalysisDigitGroupSeparator: PropTypes.string, styleDataItem: PropTypes.object, } diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index bd20f0ddcb..bfa17bb43d 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -58,7 +58,7 @@ const LayerLoader = ({ config, onLoad }) => { baseUrl, analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader - serverVersion, // Tracked entity loader + serverVersion, // Tracked entity and Event loader }).then((result) => { onLoad(result) }) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 04bc55e885..33b079f984 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -81,6 +81,10 @@ export const LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET' export const LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET' +export const LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET = + 'LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET' +export const LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET = + 'LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET' export const LAYER_EDIT_THEMATIC_MAP_TYPE_SET = 'LAYER_EDIT_THEMATIC_MAP_TYPE_SET' export const LAYER_EDIT_CLASSIFICATION_SET = 'LAYER_EDIT_CLASSIFICATION_SET' @@ -153,8 +157,8 @@ export const LAYER_EDIT_COUNT_EVENTS_OUTSIDE_OU_SET = 'LAYER_EDIT_COUNT_EVENTS_OUTSIDE_OU_SET' export const LAYER_EDIT_BAND_SET = 'LAYER_EDIT_BAND_SET' export const LAYER_EDIT_FEATURE_STYLE_SET = 'LAYER_EDIT_FEATURE_STYLE_SET' -export const LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET = - 'LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET' +export const LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET = + 'LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET' export const LAYER_EDIT_EARTH_ENGINE_PERIOD_SET = 'LAYER_EDIT_EARTH_ENGINE_PERIOD_SET' export const LAYER_EDIT_LABEL_DATA_ITEM_ID_SET = diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..3096346879 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { qualitativeColors } from './colors.js' export const VECTOR_STYLE = 'vectorStyle' export const TILE_LAYER = 'tileLayer' @@ -108,12 +109,19 @@ export const EVENT_COORDINATE_ORG_UNIT = 'ougeometry' export const EVENT_COORDINATE_ENROLLMENT = 'pigeometry' export const EVENT_COORDINATE_TRACKED_ENTITY = 'teigeometry' export const EVENT_COORDINATE_CASCADING = 'cascading' +export const EVENT_COORDINATE_GEOMETRY_SOURCE = 'geometrySource' export const COORDINATE_FIELD_NAMES = { [EVENT_COORDINATE_DEFAULT]: i18n.t('Event location'), [EVENT_COORDINATE_ORG_UNIT]: i18n.t('Organisation unit location'), [EVENT_COORDINATE_ENROLLMENT]: i18n.t('Enrollment location'), [EVENT_COORDINATE_TRACKED_ENTITY]: i18n.t('Tracked entity location'), } +export const GEOMETRY_SOURCE_COLORS = { + [EVENT_COORDINATE_DEFAULT]: qualitativeColors[0], + [EVENT_COORDINATE_ENROLLMENT]: qualitativeColors[1], + [EVENT_COORDINATE_TRACKED_ENTITY]: qualitativeColors[2], + [EVENT_COORDINATE_ORG_UNIT]: qualitativeColors[10], +} export const EVENT_CENTROID_DEFAULT = [ EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ENROLLMENT, diff --git a/src/constants/settings.js b/src/constants/settings.js index f3243b8fd5..22925266fa 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -21,6 +21,7 @@ export const SYSTEM_SETTINGS = [ 'keyHideMonthlyPeriods', 'keyHideBiMonthlyPeriods', 'keyDefaultBaseMap', + 'orgUnitCentroidsInEventsAnalytics', ...Object.keys(MAP_SERVICE_KEY_TESTS), ] diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..c3b5f1f7d5 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -63,7 +63,7 @@ export const useLayersLoader = () => { baseUrl, analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader - serverVersion, // Tracked entity loader + serverVersion, // Tracked entity and Event loader loadExtended: !!dataTable, // Event loader }) if (result.alerts) { diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..780a89faf3 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,12 +4,20 @@ import { USER_ORG_UNIT_GRANDCHILDREN, } from '@dhis2/analytics' import { WARNING_OU_BOUNDARIES_FETCH_FAILED } from '../../constants/alerts.js' +import { + EVENT_COORDINATE_CASCADING, + COORDINATE_FIELD_NAMES, +} from '../../constants/layers.js' import { getUserOrgUnitIdsByKeyword } from '../../util/orgUnits.js' import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { excludeEventsOutsideOrgUnits } from '../eventLoader.js' +import { + excludeEventsOutsideOrgUnits, + isUnsupportedFallbackField, + getGeometrySourceNames, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +737,105 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +describe('isUnsupportedFallbackField', () => { + test('an ORGANISATION_UNIT-type field is unsupported pre-2.44', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'ORGANISATION_UNIT' }, + { + minor: 43, + } + ) + ).toBe(true) + }) + + test('an ORGANISATION_UNIT-type field is supported on 2.44+', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'ORGANISATION_UNIT' }, + { + minor: 44, + } + ) + ).toBe(false) + }) + + test('a COORDINATE-type field is always supported', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'COORDINATE' }, + { + minor: 43, + } + ) + ).toBe(false) + }) + + test('an unresolved field (undefined) is treated as supported', () => { + expect(isUnsupportedFallbackField(undefined, { minor: 43 })).toBe(false) + }) +}) + +describe('getGeometrySourceNames', () => { + test('includes the built-in source names by default', () => { + expect(getGeometrySourceNames({})).toEqual(COORDINATE_FIELD_NAMES) + }) + + test('adds the main coordinate field name when it is a custom field', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'customDataElement1', + coordinateField: { name: 'My custom field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement1: 'My custom field', + }) + }) + + test('adds the fallback field name when it is a custom field', () => { + const names = getGeometrySourceNames({ + eventCoordinateFieldFallback: 'customDataElement2', + fallbackField: { name: 'My fallback field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement2: 'My fallback field', + }) + }) + + test('adds both custom main and fallback field names', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'customDataElement1', + coordinateField: { name: 'My custom field' }, + eventCoordinateFieldFallback: 'customDataElement2', + fallbackField: { name: 'My fallback field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement1: 'My custom field', + customDataElement2: 'My fallback field', + }) + }) + + test('excludes the fallback field id when it is cascading', () => { + const names = getGeometrySourceNames({ + eventCoordinateFieldFallback: EVENT_COORDINATE_CASCADING, + fallbackField: null, + }) + + expect(names).toEqual(COORDINATE_FIELD_NAMES) + }) + + test('does not add an entry when the custom field could not be resolved', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'deletedDataElement', + coordinateField: undefined, + }) + + expect(names).toEqual(COORDINATE_FIELD_NAMES) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index da3b847c16..b0a6f9584e 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -12,6 +12,9 @@ import { EVENT_SERVER_CLUSTER_COUNT, EVENT_COLOR, EVENT_RADIUS, + EVENT_COORDINATE_CASCADING, + EVENT_COORDINATE_GEOMETRY_SOURCE, + COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes } from '../constants/valueTypes.js' import { @@ -23,7 +26,10 @@ import { } from '../util/analytics.js' import { cssColor, getContrastColor } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' -import { loadEventCoordinateFieldName } from '../util/coordinatesName.js' +import { + loadEventCoordinateField, + loadHasTrackedEntityType, +} from '../util/coordinatesName.js' import { getAnalyticsRequest, loadData } from '../util/event.js' import { getBounds, @@ -41,6 +47,7 @@ import { OPTION_SET_QUERY } from '../util/requests.js' import { styleByDataItem } from '../util/styleByDataItem.js' import { formatStartEndDate, getDateArray } from '../util/time.js' import { isValidUid } from '../util/uid.js' +import { serverSupportsGeometrySource } from '../util/versionToggle.js' // OU dimension value is always an ID; property key depends on outputIdScheme const getEventOuId = (feature) => @@ -86,6 +93,36 @@ const unknownErrorAlert = { code: CUSTOM_ALERT, message: i18n.t('An unknown error occurred while reading layer data'), } +const eventCoordinateFieldFallbackUnsupportedAlert = { + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'The fallback coordinate field is not supported by this server version' + ), +} + +// VERSION-TOGGLE: ORGANISATION_UNIT fallback fields crash pre-2.44 - see +// util/versionToggle.js. Resolved via query since valueType isn't stored. +export const isUnsupportedFallbackField = (fallbackField, serverVersion) => + fallbackField?.valueType === 'ORGANISATION_UNIT' && + !serverSupportsGeometrySource(serverVersion) + +export const getGeometrySourceNames = ({ + eventCoordinateField, + coordinateField, + eventCoordinateFieldFallback, + fallbackField, +}) => ({ + ...COORDINATE_FIELD_NAMES, + ...(eventCoordinateField && + coordinateField && { + [eventCoordinateField]: coordinateField.name, + }), + ...(fallbackField && + eventCoordinateFieldFallback !== EVENT_COORDINATE_CASCADING && { + [eventCoordinateFieldFallback]: fallbackField.name, + }), +}) // Returns a promise const eventLoader = async ({ @@ -97,6 +134,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) => { const config = { ...layerConfig, @@ -117,6 +155,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) } catch (e) { if ( @@ -149,6 +188,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) => { // Config normalization // ----- @@ -161,6 +201,7 @@ const loadEventLayer = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, labelDataItem, + eventCoordinateFieldFallback: eventCoordinateFieldFallbackFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -196,6 +237,16 @@ const loadEventLayer = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + // VERSION-TOGGLE: eventCoordinateFieldFallback isn't a schema field + // pre-2.43 - see util/versionToggle.js. Read it back from the config + // blob if it wasn't saved as a native property. + if ( + eventCoordinateFieldFallbackFromConfig && + !config.eventCoordinateFieldFallback + ) { + config.eventCoordinateFieldFallback = + eventCoordinateFieldFallbackFromConfig + } if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, @@ -237,12 +288,49 @@ const loadEventLayer = async ({ config.isExtended = loadExtended + const alerts = [] + + // Fallback coordinate field + // ----- + + let fallbackField = + config.eventCoordinateFieldFallback && + config.eventCoordinateFieldFallback !== EVENT_COORDINATE_CASCADING + ? await loadEventCoordinateField({ + program, + programStage, + fieldId: config.eventCoordinateFieldFallback, + engine, + displayNameProp, + }) + : null + + if (isUnsupportedFallbackField(fallbackField, serverVersion)) { + delete config.eventCoordinateFieldFallback + fallbackField = null + alerts.push(eventCoordinateFieldFallbackUnsupportedAlert) + } + + const coordinateField = await loadEventCoordinateField({ + program, + programStage, + fieldId: eventCoordinateField, + engine, + displayNameProp, + }) + + config.geometrySourceNames = getGeometrySourceNames({ + eventCoordinateField, + coordinateField, + eventCoordinateFieldFallback: config.eventCoordinateFieldFallback, + fallbackField, + }) + const analyticsRequest = await getAnalyticsRequest(config, { analyticsEngine, nameProperty: displayNameProp, engine, }) - const alerts = [] // Legend skeleton // ----- @@ -316,6 +404,12 @@ const loadEventLayer = async ({ } if (styleDataItem) { + if (styleDataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { + config.hasTrackedEntityType = await loadHasTrackedEntityType({ + program, + engine, + }) + } await styleByDataItem(config, engine) } @@ -403,15 +497,14 @@ const loadEventLayer = async ({ // Coordinate field // ----- - const eventCoordinateFieldName = await loadEventCoordinateFieldName({ - program, - programStage, - eventCoordinateField, - engine, - displayNameProp, - }) - if (eventCoordinateFieldName) { - config.legend.coordinateFields = [eventCoordinateFieldName] + if (coordinateField) { + config.legend.coordinateFields = [coordinateField.name] + } + + if (config.eventCoordinateFieldFallback === EVENT_COORDINATE_CASCADING) { + config.legend.eventCoordinateFieldFallback = i18n.t('Cascading') + } else if (fallbackField) { + config.legend.eventCoordinateFieldFallback = fallbackField.name } // Legend items & explanation diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index fd825639f9..67c2b58da6 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -7,6 +7,7 @@ import { THEMATIC_CHOROPLETH, EE_BUFFER, NONE, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../../constants/layers.js' import { START_END_DATES } from '../../constants/periods.js' import { @@ -75,12 +76,13 @@ describe('layerEdit reducer', () => { expect(result.countFeaturesWithoutCoordinates).toBe(true) }) - it('resets columns/programStage/styleDataItem/labelDataItem when the program changes', () => { + it('resets columns/programStage/styleDataItem/labelDataItem/hasTrackedEntityType when the program changes', () => { const state = { columns: [{ dimension: 'dx' }], programStage: { id: 'stage1' }, styleDataItem: { id: 'sd1' }, labelDataItem: { id: 'ld1' }, + hasTrackedEntityType: true, } const result = layerEdit(state, { @@ -95,9 +97,36 @@ describe('layerEdit reducer', () => { programStage: null, styleDataItem: null, labelDataItem: null, + hasTrackedEntityType: null, }) }) + it("resets hasTrackedEntityType to null pending the new program's TEI query, closing the stale-cascade window", () => { + const afterProgramChange = layerEdit( + { + hasTrackedEntityType: true, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'cascading', + }, + { + type: types.LAYER_EDIT_PROGRAM_SET, + program: { id: 'prog2' }, + } + ) + + expect(afterProgramChange.hasTrackedEntityType).toBe(null) + + const afterStyleSet = layerEdit(afterProgramChange, { + type: types.LAYER_EDIT_STYLE_DATA_ITEM_SET, + dataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }) + + expect(Object.keys(afterStyleSet.styleDataItem.values)).toEqual([ + 'psigeometry', + 'ougeometry', + ]) + }) + it('sets program to null when the program is cleared', () => { const result = layerEdit( {}, @@ -462,6 +491,27 @@ describe('layerEdit reducer', () => { expect(result.styleDataItem).toBe(dataItem) }) + it('populates styleDataItem.values immediately when geometry source is selected', () => { + const result = layerEdit( + { + eventCoordinateField: 'customDataElement1', + eventCoordinateFieldFallback: 'customDataElement2', + }, + { + type: types.LAYER_EDIT_STYLE_DATA_ITEM_SET, + dataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: 'Geometry source', + }, + } + ) + + expect(result.styleDataItem.values).toEqual({ + customDataElement1: expect.any(String), + customDataElement2: expect.any(String), + }) + }) + it('sets option-set options and clears method/classes/colorScale', () => { const state = { method: CLASSIFICATION_EQUAL_INTERVALS, @@ -812,29 +862,189 @@ describe('layerEdit reducer', () => { expect(result.eventCoordinateFieldType).toBe('COORDINATE') }) - describe('LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET', () => { - it('deletes fallbackCoordinateField when set to NONE', () => { + describe('LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET', () => { + it('deletes eventCoordinateFieldFallback when set to NONE', () => { const result = layerEdit( - { fallbackCoordinateField: 'field1' }, + { eventCoordinateFieldFallback: 'field1' }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: NONE, } ) - expect(result.fallbackCoordinateField).toBeUndefined() + expect(result.eventCoordinateFieldFallback).toBeUndefined() }) - it('sets fallbackCoordinateField for any other value', () => { + it('sets eventCoordinateFieldFallback for any other value', () => { const result = layerEdit( {}, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: 'field1', + } + ) + + expect(result.eventCoordinateFieldFallback).toBe('field1') + }) + + it('leaves styleDataItem and labelDataItem set to geometry source when fallback is cleared', () => { + const result = layerEdit( + { + eventCoordinateFieldFallback: 'field1', + styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: NONE, + } + ) + + expect(result.styleDataItem.id).toBe( + EVENT_COORDINATE_GEOMETRY_SOURCE + ) + expect(result.labelDataItem).toEqual({ + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + }) + }) + + it('leaves unrelated styleDataItem and labelDataItem untouched when fallback is cleared', () => { + const result = layerEdit( + { + eventCoordinateFieldFallback: 'field1', + styleDataItem: { id: 'someOtherField' }, + labelDataItem: { id: 'someOtherField' }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: NONE, + } + ) + + expect(result.styleDataItem).toEqual({ id: 'someOtherField' }) + expect(result.labelDataItem).toEqual({ id: 'someOtherField' }) + }) + + it('leaves styleDataItem and labelDataItem set to geometry source untouched when fallback is changed (not cleared)', () => { + const result = layerEdit( + { + eventCoordinateFieldFallback: 'field1', + styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: 'cascading', + } + ) + + expect(result.styleDataItem.id).toBe( + EVENT_COORDINATE_GEOMETRY_SOURCE + ) + expect(result.labelDataItem).toEqual({ + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + }) + }) + + it('syncs styleDataItem.values to the new fallback when styling by geometry source', () => { + const result = layerEdit( + { + eventCoordinateField: 'ougeometry', + eventCoordinateFieldFallback: 'field1', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: '#111', field1: '#222' }, + }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: 'field2', + fieldType: 'COORDINATE', + } + ) + + expect(result.styleDataItem.values).toEqual( + expect.objectContaining({ + ougeometry: '#111', + field2: expect.any(String), + }) + ) + expect(result.styleDataItem.values.field1).toBeUndefined() + }) + }) + + describe('LAYER_EDIT_EVENT_COORDINATE_FIELD_SET', () => { + it('leaves styleDataItem untouched when not styling by geometry source', () => { + const result = layerEdit( + { styleDataItem: { id: 'someOtherField' } }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET, fieldId: 'field1', + fieldType: 'COORDINATE', } ) - expect(result.fallbackCoordinateField).toBe('field1') + expect(result.styleDataItem).toEqual({ id: 'someOtherField' }) + }) + + it('syncs styleDataItem.values to the new main field when styling by geometry source', () => { + const result = layerEdit( + { + eventCoordinateField: 'ougeometry', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: '#111' }, + }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET, + fieldId: 'field1', + fieldType: 'COORDINATE', + } + ) + + expect(result.styleDataItem.values).toEqual({ + field1: expect.any(String), + }) + }) + }) + + describe('LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET', () => { + it('sets hasTrackedEntityType', () => { + const result = layerEdit( + {}, + { + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value: true, + } + ) + + expect(result.hasTrackedEntityType).toBe(true) + }) + + it('syncs styleDataItem.values when styling by geometry source (cascading, TEI type resolved)', () => { + const result = layerEdit( + { + eventCoordinateFieldFallback: 'cascading', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + }, + { + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value: true, + } + ) + + expect(Object.keys(result.styleDataItem.values)).toEqual( + expect.arrayContaining([ + 'pigeometry', + 'psigeometry', + 'teigeometry', + 'ougeometry', + ]) + ) }) }) @@ -1323,4 +1533,35 @@ describe('layerEdit reducer', () => { expect(layerEdit(state, { type: 'UNKNOWN' })).toBe(state) }) + + it('sets eventCoordinateFieldFallback and eventCoordinateFieldFallbackType together', () => { + const result = layerEdit( + {}, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: 'deId123456', + fieldType: 'ORGANISATION_UNIT', + } + ) + + expect(result.eventCoordinateFieldFallback).toBe('deId123456') + expect(result.eventCoordinateFieldFallbackType).toBe( + 'ORGANISATION_UNIT' + ) + }) + + it('clears eventCoordinateFieldFallback and eventCoordinateFieldFallbackType when set to none', () => { + const state = { + eventCoordinateFieldFallback: 'deId123456', + eventCoordinateFieldFallbackType: 'ORGANISATION_UNIT', + } + + const result = layerEdit(state, { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, + fieldId: 'none', + }) + + expect(result.eventCoordinateFieldFallback).toBeUndefined() + expect(result.eventCoordinateFieldFallbackType).toBeUndefined() + }) }) diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 35ed32059d..4c5531784d 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -7,6 +7,7 @@ import { THEMATIC_CHOROPLETH, EE_BUFFER, NONE, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../constants/layers.js' import { START_END_DATES } from '../constants/periods.js' import { @@ -19,6 +20,36 @@ import { splitFilterColumns, compactFilterColumns, } from '../util/analytics.js' +import { + getDefaultGeometrySourceColor, + getPossibleGeometrySources, +} from '../util/coordinatesName.js' + +const syncGeometrySourceValues = (state) => { + if (state.styleDataItem?.id !== EVENT_COORDINATE_GEOMETRY_SOURCE) { + return state.styleDataItem + } + const sources = getPossibleGeometrySources( + state.eventCoordinateField, + state.eventCoordinateFieldFallback, + state.hasTrackedEntityType + ) + const prevValues = state.styleDataItem.values || {} + return { + ...state.styleDataItem, + values: Object.fromEntries( + sources.map((id) => [ + id, + prevValues[id] ?? + getDefaultGeometrySourceColor(id, { + eventCoordinateField: state.eventCoordinateField, + eventCoordinateFieldFallback: + state.eventCoordinateFieldFallback, + }), + ]) + ), + } +} const layerEdit = (state = null, action) => { let newState @@ -42,6 +73,7 @@ const layerEdit = (state = null, action) => { programStage: null, styleDataItem: null, labelDataItem: null, + hasTrackedEntityType: null, } case types.LAYER_EDIT_PROGRAM_STAGE_SET: @@ -212,10 +244,12 @@ const layerEdit = (state = null, action) => { } case types.LAYER_EDIT_STYLE_DATA_ITEM_SET: - return { + newState = { ...state, styleDataItem: action.dataItem, } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState // Set options to data element option set case types.LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET: @@ -254,6 +288,18 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET: + return { + ...state, + styleDataItem: { + ...state.styleDataItem, + values: { + ...state.styleDataItem?.values, + [action.value]: action.color, + }, + }, + } + case types.LAYER_EDIT_THEMATIC_MAP_TYPE_SET: newState = { ...state, @@ -348,21 +394,31 @@ const layerEdit = (state = null, action) => { return newState case types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET: - return { + newState = { ...state, eventCoordinateField: action.fieldId, eventCoordinateFieldType: action.fieldType, } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState - case types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET: + case types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET: newState = { ...state } if (action.fieldId === NONE) { - delete newState.fallbackCoordinateField + delete newState.eventCoordinateFieldFallback + delete newState.eventCoordinateFieldFallbackType } else { - newState.fallbackCoordinateField = action.fieldId + newState.eventCoordinateFieldFallback = action.fieldId + newState.eventCoordinateFieldFallbackType = action.fieldType } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState + + case types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET: + newState = { ...state, hasTrackedEntityType: action.value } + newState.styleDataItem = syncGeometrySourceValues(newState) return newState case types.LAYER_EDIT_EVENT_CLUSTERING_SET: diff --git a/src/util/__tests__/event.spec.js b/src/util/__tests__/event.spec.js index 415200f71e..d831aeb748 100644 --- a/src/util/__tests__/event.spec.js +++ b/src/util/__tests__/event.spec.js @@ -1,3 +1,4 @@ +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { getAnalyticsRequest } from '../event.js' const mockRequestInstance = { @@ -93,6 +94,34 @@ describe('getAnalyticsRequest', () => { expect(getDimensionCalls('fakeDataElId')[0][1]).toBe('LT:80') }) + it('sends a specific fallback field as fallbackCoordinateField', async () => { + const layer = { + ...baseLayer, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'pigeometry', + } + + await getAnalyticsRequest(layer, baseContext) + + expect(mockRequestInstance.withParameters).toHaveBeenCalledWith({ + fallbackCoordinateField: 'pigeometry', + }) + }) + + it('sends defaultCoordinateFallback for a cascading fallback', async () => { + const layer = { + ...baseLayer, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'cascading', + } + + await getAnalyticsRequest(layer, baseContext) + + expect(mockRequestInstance.withParameters).toHaveBeenCalledWith({ + defaultCoordinateFallback: true, + }) + }) + it('falls back to start/end dates when no period is selected', async () => { await getAnalyticsRequest(baseLayer, baseContext) @@ -138,4 +167,28 @@ describe('getAnalyticsRequest', () => { '2023Q2', ]) }) + + it('never adds geometrySource as a dimension when it is the labelDataItem', async () => { + const layer = { + ...baseLayer, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + } + + await getAnalyticsRequest(layer, baseContext) + + expect( + getDimensionCalls(EVENT_COORDINATE_GEOMETRY_SOURCE) + ).toHaveLength(0) + }) + + it('adds a non-geometrySource labelDataItem as a dimension', async () => { + const layer = { + ...baseLayer, + labelDataItem: { id: 'fakeDataElId' }, + } + + await getAnalyticsRequest(layer, baseContext) + + expect(getDimensionCalls('fakeDataElId')).toHaveLength(1) + }) }) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 5db9075a14..b95e2f2e77 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -375,6 +375,56 @@ describe('cleanMapConfig', () => { expect(view).not.toHaveProperty('noDataLegend') }) + test('keeps eventCoordinateFieldFallback as a native property on 2.43+', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Test', + opacity: 1, + eventCoordinateFieldFallback: 'cascading', + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + serverVersion: { minor: 43 }, + }) + const view = cleanedConfig.mapViews[0] + expect(view.eventCoordinateFieldFallback).toBe('cascading') + expect(view.config).toBeUndefined() + }) + + test('stores eventCoordinateFieldFallback in the config blob pre-2.43', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Test', + opacity: 1, + eventCoordinateFieldFallback: 'cascading', + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + serverVersion: { minor: 42 }, + }) + const view = cleanedConfig.mapViews[0] + expect(JSON.parse(view.config).eventCoordinateFieldFallback).toBe( + 'cascading' + ) + expect(view).not.toHaveProperty('eventCoordinateFieldFallback') + }) + test('serializes unclassifiedLegend into config JSON and removes it from the layer', () => { const cleanedConfig = cleanMapConfig({ config: { diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 82247a8d98..725dbb337b 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -1,3 +1,4 @@ +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { GEO_TYPE_POINT, GEO_TYPE_FEATURE, @@ -303,6 +304,15 @@ describe('geojson utils', () => { name: newItem.name, }) }) + it('Should not add geometrySource as a dimension - the backend returns it automatically', () => { + const geometrySourceItem = { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: 'Geometry source', + } + const result = addStyleDataItem(dummyDataItems, geometrySourceItem) + expect(result).not.toBe(dummyDataItems) // New array, not === + expect(result).toEqual(dummyDataItems) + }) }) describe('getBounds', () => { diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 5baf91e5c1..92baddde00 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -1,4 +1,12 @@ -import { EVENT_COLOR, EVENT_RADIUS } from '../../constants/layers.js' +import { qualitativeColors } from '../../constants/colors.js' +import { + EVENT_COLOR, + EVENT_RADIUS, + EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_ORG_UNIT, + EVENT_COORDINATE_CASCADING, + GEOMETRY_SOURCE_COLORS, +} from '../../constants/layers.js' import { numberValueTypes, booleanValueTypes, @@ -605,6 +613,242 @@ describe('styleByDataItem', () => { }) }) + it('should resolve geometrySource names from config.geometrySourceNames', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red', abcDataElementUid1: 'blue' }, + }, + eventCoordinateField: 'abcDataElementUid1', + eventCoordinateFieldFallback: 'ougeometry', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + abcDataElementUid1: 'My custom field', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: + 'abcDataElementUid1', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + expect(result.data[1].properties).toMatchObject({ + value: 'My custom field', + color: 'blue', + }) + expect(result.legend.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Organisation unit location', + sourceId: 'ougeometry', + }), + expect.objectContaining({ + name: 'My custom field', + sourceId: 'abcDataElementUid1', + }), + ]) + ) + }) + + it('should fall back to COORDINATE_FIELD_NAMES for built-in sources when config.geometrySourceNames is absent', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red' }, + }, + eventCoordinateField: 'ougeometry', + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + }) + + it('should default geometrySource to the main coordinate field when no fallback is configured', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red' }, + }, + eventCoordinateField: 'ougeometry', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { properties: {} }, // no fallback configured - backend never sends geometrySource + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + }) + + it('should exclude stale styleDataItem.values entries no longer present in the data', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { + ougeometry: 'red', + abcDataElementUid1: 'blue', + }, + }, + eventCoordinateField: 'ougeometry', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items).toHaveLength(1) + expect(result.legend.items[0]).toMatchObject({ + sourceId: 'ougeometry', + name: 'Organisation unit location', + }) + }) + + it('should order the legend with the main field first, then fallback in cascading order', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { + ougeometry: 'c1', + teigeometry: 'c2', + psigeometry: 'c3', + pigeometry: 'c4', + customDataElement1: 'c5', + }, + }, + eventCoordinateField: 'customDataElement1', + eventCoordinateFieldFallback: EVENT_COORDINATE_CASCADING, + hasTrackedEntityType: true, + geometrySourceNames: { + customDataElement1: 'My custom field', + }, + data: [], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items.map((i) => i.sourceId)).toEqual([ + 'customDataElement1', + 'psigeometry', + 'pigeometry', + 'teigeometry', + 'ougeometry', + ]) + }) + + it('should give a source missing from styleDataItem.values a default color without adding a legend entry', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items).toHaveLength(0) + expect(result.data[0].properties.color).toBe( + GEOMETRY_SOURCE_COLORS[EVENT_COORDINATE_ORG_UNIT] + ) + }) + + it('should assign a source the same color regardless of which other sources are present', async () => { + const baseConfig = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + eventCoordinateField: 'customDataElement1', + geometrySourceNames: { + customDataElement1: 'My custom field', + }, + } + + const resultAlone = await styleByDataItem({ + ...baseConfig, + legend: { items: [] }, + data: [{ properties: {} }], // resolves to customDataElement1 (no fallback) + }) + const resultAlongsideOthers = await styleByDataItem({ + ...baseConfig, + legend: { items: [] }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'pigeometry', + }, + }, + { properties: {} }, // resolves to customDataElement1 + ], + }) + + const colorAlone = resultAlone.data[0].properties.color + const colorAlongsideOthers = + resultAlongsideOthers.data[2].properties.color + + expect(colorAlone).toBe(colorAlongsideOthers) + expect(qualitativeColors).toContain(colorAlone) + }) + it('should include unclassified and no-data events when configured (option set)', async () => { const config = { styleDataItem: { diff --git a/src/util/__tests__/versionToggle.spec.js b/src/util/__tests__/versionToggle.spec.js new file mode 100644 index 0000000000..fb76a73320 --- /dev/null +++ b/src/util/__tests__/versionToggle.spec.js @@ -0,0 +1,88 @@ +import { + serverSupportsGeometrySource, + serverSupportsOrgUnitCoordinateField, + serverSupportsEventCoordinateFieldFallback, +} from '../versionToggle.js' + +describe('serverSupportsGeometrySource', () => { + it('returns false below 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 43 })).toBe(false) + }) + + it('returns true at 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 44 })).toBe(true) + }) + + it('returns true above 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 45 })).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsGeometrySource(undefined)).toBe(false) + }) +}) + +describe('serverSupportsOrgUnitCoordinateField', () => { + it('returns false on 2.40 below patch 8', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 40, patch: 7 }) + ).toBe(false) + }) + + it('returns true on 2.40 from patch 8', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 40, patch: 8 }) + ).toBe(true) + }) + + it('returns false on 2.41 below patch 4', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 41, patch: 3 }) + ).toBe(false) + }) + + it('returns true on 2.41 from patch 4', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 41, patch: 4 }) + ).toBe(true) + }) + + it('returns true on 2.42 and above regardless of patch', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 42, patch: 0 }) + ).toBe(true) + expect( + serverSupportsOrgUnitCoordinateField({ minor: 45, patch: 0 }) + ).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsOrgUnitCoordinateField(undefined)).toBe(false) + }) +}) + +describe('serverSupportsEventCoordinateFieldFallback', () => { + it('returns false below 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 42 })).toBe( + false + ) + }) + + it('returns true at 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 43 })).toBe( + true + ) + }) + + it('returns true above 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 44 })).toBe( + true + ) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsEventCoordinateFieldFallback(undefined)).toBe( + false + ) + }) +}) diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index 0fcdef009d..04b40add27 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -1,22 +1,35 @@ -import { COORDINATE_FIELD_NAMES } from '../constants/layers.js' +import { qualitativeColors } from '../constants/colors.js' +import { + COORDINATE_FIELD_NAMES, + GEOMETRY_SOURCE_COLORS, + EVENT_COORDINATE_CASCADING, + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_ORG_UNIT, + EVENT_COORDINATE_TRACKED_ENTITY, + NONE, +} from '../constants/layers.js' import { EVENT_PROGRAM_STAGE_DATA_ELEMENTS_QUERY, EVENT_PROGRAM_ATTRIBUTES_QUERY, + EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY, } from '../util/event.js' -export const loadEventCoordinateFieldName = async ({ +// Resolves a coordinate field id to its name/valueType - only custom DE/TEA +// fields need the query, built-in fields (psigeometry etc.) are static. +export const loadEventCoordinateField = async ({ program, programStage, - eventCoordinateField, + fieldId, engine, displayNameProp, }) => { - if (!eventCoordinateField) { - return + if (!fieldId) { + return undefined } - if (COORDINATE_FIELD_NAMES[eventCoordinateField]) { - return COORDINATE_FIELD_NAMES[eventCoordinateField] + if (COORDINATE_FIELD_NAMES[fieldId]) { + return { name: COORDINATE_FIELD_NAMES[fieldId] } } const { programStage: programStageData } = await engine.query( @@ -28,10 +41,13 @@ export const loadEventCoordinateFieldName = async ({ const { programStageDataElements } = programStageData if (Array.isArray(programStageDataElements)) { const coordElement = programStageDataElements.find( - (d) => d.dataElement.id === eventCoordinateField + (d) => d.dataElement.id === fieldId ) if (coordElement) { - return coordElement.dataElement.name + return { + name: coordElement.dataElement.name, + valueType: coordElement.dataElement.valueType, + } } } @@ -47,10 +63,73 @@ export const loadEventCoordinateFieldName = async ({ const { programTrackedEntityAttributes } = programData if (Array.isArray(programTrackedEntityAttributes)) { const coordAttribute = programTrackedEntityAttributes.find( - (d) => d.trackedEntityAttribute.id === eventCoordinateField + (d) => d.trackedEntityAttribute.id === fieldId ) if (coordAttribute) { - return coordAttribute.trackedEntityAttribute.name + return { + name: coordAttribute.trackedEntityAttribute.name, + valueType: coordAttribute.trackedEntityAttribute.valueType, + } } } + + return undefined +} + +export const resolveGeometrySourceName = (id, geometrySourceNames) => + geometrySourceNames?.[id] ?? id + +export const getDefaultGeometrySourceColor = ( + id, + { eventCoordinateField, eventCoordinateFieldFallback } +) => { + if (GEOMETRY_SOURCE_COLORS[id]) { + return GEOMETRY_SOURCE_COLORS[id] + } + if (id === eventCoordinateField) { + return qualitativeColors[11] + } + if (id === eventCoordinateFieldFallback) { + return qualitativeColors[9] + } + // Unrecognized source id - last unused palette slot, avoids collisions + return qualitativeColors[8] +} + +const expandField = (fieldId, hasTei) => { + if (fieldId === EVENT_COORDINATE_CASCADING) { + return hasTei + ? [ + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_TRACKED_ENTITY, + EVENT_COORDINATE_ORG_UNIT, + ] + : [EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ORG_UNIT] + } + return [fieldId] +} + +export const loadHasTrackedEntityType = async ({ program, engine }) => { + const { program: programData } = await engine.query( + EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY, + { variables: { id: program.id } } + ) + return !!programData?.trackedEntityType?.id +} + +export const getPossibleGeometrySources = ( + eventCoordinateField, + eventCoordinateFieldFallback, + hasTei +) => { + const main = expandField( + eventCoordinateField ?? EVENT_COORDINATE_DEFAULT, + hasTei + ) + const fallback = + eventCoordinateFieldFallback && eventCoordinateFieldFallback !== NONE + ? expandField(eventCoordinateFieldFallback, hasTei) + : [] + return [...new Set([...main, ...fallback])] } diff --git a/src/util/event.js b/src/util/event.js index 4342b9e1d3..ca773ea4b8 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -2,6 +2,7 @@ import { EVENT_CLIENT_PAGE_SIZE, EVENT_COORDINATE_CASCADING, EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../constants/layers.js' import { getOrgUnitsFromRows, @@ -32,6 +33,16 @@ export const EVENT_PROGRAM_ATTRIBUTES_QUERY = { }, } +export const EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY = { + program: { + resource: 'programs', + id: ({ id }) => id, + params: { + fields: 'trackedEntityType[id]', + }, + }, +} + // Empty filter sometimes returned for saved maps // Dimension without filter and empty items array returns false const isValidDimension = ({ dimension, filter, items }) => @@ -73,7 +84,7 @@ export const getAnalyticsRequest = async ( labelDataItem, eventStatus, eventCoordinateField = EVENT_COORDINATE_DEFAULT, - fallbackCoordinateField, + eventCoordinateFieldFallback, relativePeriodDate, isExtended, countFeaturesWithoutCoordinates, @@ -90,6 +101,7 @@ export const getAnalyticsRequest = async ( // Add label data item dimension if not already in the request if ( labelDataItem?.id && + labelDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !dataItems.some((item) => item.dimension === labelDataItem.id) ) { dataItems.push({ dimension: labelDataItem.id }) @@ -155,14 +167,14 @@ export const getAnalyticsRequest = async ( analyticsRequest = analyticsRequest.withCoordinateField(eventCoordinateField) - if (fallbackCoordinateField) { - if (fallbackCoordinateField === EVENT_COORDINATE_CASCADING) { + if (eventCoordinateFieldFallback) { + if (eventCoordinateFieldFallback === EVENT_COORDINATE_CASCADING) { analyticsRequest = analyticsRequest.withParameters({ defaultCoordinateFallback: true, }) } else { analyticsRequest = analyticsRequest.withParameters({ - fallbackCoordinateField, + fallbackCoordinateField: eventCoordinateFieldFallback, }) } } diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..ef4cb57b2a 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -8,6 +8,7 @@ import { THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { serverSupportsEventCoordinateFieldFallback } from './versionToggle.js' // TODO: get latitude, longitude, zoom from map + basemap: 'none' const validMapProperties = [ @@ -41,6 +42,7 @@ const validLayerProperties = [ 'displayName', 'endDate', 'eventCoordinateField', + 'eventCoordinateFieldFallback', 'eventClustering', 'eventPointColor', 'eventPointRadius', @@ -119,7 +121,7 @@ export const cleanMapConfig = ({ ...omitBy(isNil, pick(validMapProperties, config)), ...getBasemapPayload(config.basemap, defaultBasemapId, serverVersion), mapViews: config.mapViews.map((view) => - cleanLayerConfig(view, cleanMapviewConfig) + cleanLayerConfig(view, cleanMapviewConfig, serverVersion) ), }) @@ -147,17 +149,18 @@ const getBasemapPayload = (basemap, defaultBasemapId, serverVersion) => { } } -const cleanLayerConfig = (layer, cleanMapviewConfig) => ({ +const cleanLayerConfig = (layer, cleanMapviewConfig, serverVersion) => ({ ...models2objects( pick(validLayerProperties, { ...layer, hidden: layer.isVisible === false, }), - cleanMapviewConfig + cleanMapviewConfig, + serverVersion ), }) -const buildCommonLayerConfigData = (layer) => { +const buildCommonLayerConfigData = (layer, serverVersion) => { const configData = {} if (layer.legendDecimalPlaces !== undefined) { configData.legendDecimalPlaces = layer.legendDecimalPlaces @@ -180,10 +183,20 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + // VERSION-TOGGLE: eventCoordinateFieldFallback isn't a schema field + // pre-2.43 - see util/versionToggle.js. Store it in the config blob + // instead so it still round-trips on older servers. + if ( + layer.eventCoordinateFieldFallback && + !serverSupportsEventCoordinateFieldFallback(serverVersion) + ) { + configData.eventCoordinateFieldFallback = + layer.eventCoordinateFieldFallback + } return configData } -const deleteCommonLayerConfigProps = (layer) => { +const deleteCommonLayerConfigProps = (layer, serverVersion) => { if (layer.noDataLegend) { layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility } @@ -194,6 +207,9 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + if (!serverSupportsEventCoordinateFieldFallback(serverVersion)) { + delete layer.eventCoordinateFieldFallback + } } const buildEarthEngineLayerConfigData = (layer) => { @@ -235,8 +251,52 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.periodType } +const applyEarthEngineLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = JSON.stringify(buildEarthEngineLayerConfigData(layer)) + } + deleteEarthEngineLayerProps(layer) +} + +const applyTrackedEntityLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = JSON.stringify(buildTrackedEntityLayerConfigData(layer)) + } + deleteTrackedEntityLayerProps(layer) +} + +const applyGeoJsonUrlLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = { + ...layer.config, + featureStyle: { ...layer.featureStyle }, + } + } + delete layer.featureStyle +} + +const applyCommonLayerConfig = (layer, cleanMapviewConfig, serverVersion) => { + if (cleanMapviewConfig) { + const configData = buildCommonLayerConfigData(layer, serverVersion) + if (Object.keys(configData).length) { + layer.config = JSON.stringify(configData) + } + } + deleteCommonLayerConfigProps(layer, serverVersion) +} + +const applyLayerTypeConfigByType = { + [EARTH_ENGINE_LAYER]: applyEarthEngineLayerConfig, + [TRACKED_ENTITY_LAYER]: applyTrackedEntityLayerConfig, + [GEOJSON_URL_LAYER]: applyGeoJsonUrlLayerConfig, + [EVENT_LAYER]: applyCommonLayerConfig, + [THEMATIC_LAYER]: applyCommonLayerConfig, + [ORG_UNIT_LAYER]: applyCommonLayerConfig, + [FACILITY_LAYER]: applyCommonLayerConfig, +} + // TODO: This feels hacky, find better way to clean map configs before saving -const models2objects = (layer, cleanMapviewConfig) => { +const models2objects = (layer, cleanMapviewConfig, serverVersion) => { const { layer: layerType } = layer Object.keys(layer).forEach((key) => { @@ -249,41 +309,9 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.rows = layer.rows.map(cleanDimension) } - if (layerType === EARTH_ENGINE_LAYER) { - if (cleanMapviewConfig) { - layer.config = JSON.stringify( - buildEarthEngineLayerConfigData(layer) - ) - } - deleteEarthEngineLayerProps(layer) - } else if (layerType === TRACKED_ENTITY_LAYER) { - if (cleanMapviewConfig) { - layer.config = JSON.stringify( - buildTrackedEntityLayerConfigData(layer) - ) - } - deleteTrackedEntityLayerProps(layer) - } else if (layerType === GEOJSON_URL_LAYER) { - if (cleanMapviewConfig) { - layer.config = { - ...layer.config, - featureStyle: { ...layer.featureStyle }, - } - } - delete layer.featureStyle - } else if ( - layerType === EVENT_LAYER || - layerType === THEMATIC_LAYER || - layerType === ORG_UNIT_LAYER || - layerType === FACILITY_LAYER - ) { - if (cleanMapviewConfig) { - const configData = buildCommonLayerConfigData(layer) - if (Object.keys(configData).length) { - layer.config = JSON.stringify(configData) - } - } - deleteCommonLayerConfigProps(layer) + const applyLayerTypeConfig = applyLayerTypeConfigByType[layerType] + if (applyLayerTypeConfig) { + applyLayerTypeConfig(layer, cleanMapviewConfig, serverVersion) } delete layer.id diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372a..6b1983d378 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,6 +1,7 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../constants/layers.js' export const EVENT_ID_FIELD = 'psi' @@ -141,6 +142,7 @@ export const createEventFeatures = (response, config = {}) => { // Include column for data element used for styling (if not already used in filter) export const addStyleDataItem = (dataItems, styleDataItem) => styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !dataItems.find((item) => item.dimension === styleDataItem.id) ? [ ...dataItems, diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 0d5913f2b3..e51c6454df 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -5,9 +5,17 @@ import { CLASSIFICATION_PREDEFINED, CLASSIFICATION_LOGARITHMIC, CLASSIFICATION_STANDARD_DEVIATION, + EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_DEFAULT, + COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' +import { + getDefaultGeometrySourceColor, + getPossibleGeometrySources, + resolveGeometrySourceName, +} from '../util/coordinatesName.js' import { OPTION_SET_QUERY, LEGEND_SET_QUERY } from '../util/requests.js' import { getLegendItemForValue } from './classify.js' import { getAutomaticLegendItems, getPredefinedLegendItems } from './legend.js' @@ -68,7 +76,9 @@ const addFeature = (acc, feature, { item, value }) => { // This function is modifiyng the config object before it's added to the redux store export const styleByDataItem = async (config, engine) => { const { styleDataItem } = config - if (styleDataItem.optionSet) { + if (styleDataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { + await styleByGeometrySource(config) + } else if (styleDataItem.optionSet) { await styleByOptionSet(config, engine) } else if (numberValueTypes.includes(styleDataItem.valueType)) { await styleByNumeric(config, engine) @@ -81,6 +91,69 @@ export const styleByDataItem = async (config, engine) => { return config } +const styleByGeometrySource = async (config) => { + const { + styleDataItem, + data, + legend, + eventPointRadius, + eventCoordinateField, + eventCoordinateFieldFallback, + hasTrackedEntityType, + } = config + const { values } = styleDataItem + const names = config.geometrySourceNames || COORDINATE_FIELD_NAMES + const possibleSources = getPossibleGeometrySources( + eventCoordinateField, + eventCoordinateFieldFallback, + hasTrackedEntityType + ) + + // Ordered main field first, then fallback (cascading order if applicable). + legend.unit = i18n.t('Coordinate source') + legend.items = possibleSources + .filter((sourceId) => values?.[sourceId]) + .map((sourceId) => ({ + name: resolveGeometrySourceName(sourceId, names), + color: values[sourceId], + sourceId, + })) + stampLegendItems(legend.items, eventPointRadius) + + const itemBySource = Object.fromEntries( + legend.items.map((i) => [i.sourceId, i]) + ) + + config.data = data.reduce((acc, feature) => { + // No fallback configured means the point can only be from the main field. + const geometrySource = + feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] ?? + eventCoordinateField ?? + EVENT_COORDINATE_DEFAULT + + // Backfills a color without adding a legend entry for it. + if (!itemBySource[geometrySource]) { + itemBySource[geometrySource] = { + color: getDefaultGeometrySourceColor(geometrySource, { + eventCoordinateField, + eventCoordinateFieldFallback, + }), + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + colorGroup: legend.items.length, + } + } + + addFeature(acc, feature, { + item: itemBySource[geometrySource], + value: resolveGeometrySourceName(geometrySource, names), + }) + return acc + }, []) + + return config +} + const styleByDefault = async (config, engine) => { const { styleDataItem, diff --git a/src/util/versionToggle.js b/src/util/versionToggle.js new file mode 100644 index 0000000000..30f1fb0bb9 --- /dev/null +++ b/src/util/versionToggle.js @@ -0,0 +1,20 @@ +// VERSION-TOGGLE: pre-2.44, an eventCoordinateFieldFallback pointed at a +// custom ORGANISATION_UNIT field crashes analytics (E7145) and geometrySource +// is never returned. 2.44+ fixes E7145 and returns geometrySource whenever a +// fallback is configured. +export const serverSupportsGeometrySource = (serverVersion) => + serverVersion?.minor >= 44 + +// VERSION-TOGGLE: ORGANISATION_UNIT-type fields became usable as a +// (non-fallback) coordinate field in 2.40.8, 2.41.4, and 2.42.0 - see +// DHIS2-19010. +export const serverSupportsOrgUnitCoordinateField = (serverVersion) => + (serverVersion?.minor === 40 && serverVersion.patch >= 8) || + (serverVersion?.minor === 41 && serverVersion.patch >= 4) || + serverVersion?.minor >= 42 + +// VERSION-TOGGLE: mapView.eventCoordinateFieldFallback isn't a schema field +// pre-2.43, so it can't be saved as a native property - store it in the +// config JSON blob instead below that version. +export const serverSupportsEventCoordinateFieldFallback = (serverVersion) => + serverVersion?.minor >= 43