diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index fe40df1da..8ee4fa293 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -45,6 +45,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * [OSDEV-2369](https://opensupplyhub.atlassian.net/browse/OSDEV-2369) - As part of the Production Location page redesign, implemented the "Contribute to this profile" section in the sidebar. The section includes: Suggest Correction (link to the contribute flow), Report Duplicate and Dispute Claim (mailto links; Dispute Claim is shown only when the facility is claimed by someone else), and Report Closed / Report Reopened. Report Closed/Reopened opens a dialog where logged-in users can submit a reason; anonymous users see a prompt to log in. * [OSDEV-2375](https://opensupplyhub.atlassian.net/browse/OSDEV-2375) - Created UI for the location name, OS ID, and "Understanding Data Sources" sections. Introduced `doc/frontend.md` with UI development considerations. * [OSDEV-2366](https://opensupplyhub.atlassian.net/browse/OSDEV-2366) - Added "Jump to" section to the sidebar with links to the different sections of the Production Location page. +* [OSDEV-2373](https://opensupplyhub.atlassian.net/browse/OSDEV-2373) - Implemented the Geographical Information section on the Production Location page, displaying an interactive satellite map with zoom controls, location centering, and "Open in Google Maps" link. Added Address and Coordinates data points below the map, each with contributor metadata and a drawer showing all contributions for that field. * [OSDEV-2368](https://opensupplyhub.atlassian.net/browse/OSDEV-2368) - Integrated the Partner Data section into the Production Location page: * Added `PartnerDataContainer` that fetches partner field groups from the API and renders them when partner data is available for a production location. * Each partner group is displayed as a collapsible `PartnerSectionItem` with a toggle switch, partner icon, helper text tooltip, and a two-column layout of partner fields. diff --git a/src/react/src/__tests__/components/ContributionsDrawer.test.jsx b/src/react/src/__tests__/components/ContributionsDrawer.test.jsx index ab140dd47..c4f7e7ee3 100644 --- a/src/react/src/__tests__/components/ContributionsDrawer.test.jsx +++ b/src/react/src/__tests__/components/ContributionsDrawer.test.jsx @@ -116,6 +116,27 @@ describe('ContributionsDrawer', () => { expect(screen.getByTestId('contribution-card')).toBeInTheDocument(); }); + test('does not count null promotedContribution as an anonymous contributor', () => { + renderContributionsDrawer({ + open: true, + onClose: () => {}, + fieldName: 'Address', + promotedContribution: null, + contributions: [ + { + value: '123 Main St', + sourceName: 'Source A', + date: '2022-01-01', + userId: 1, + }, + ], + }); + + expect( + screen.getByTestId('contributions-drawer-subtitle'), + ).toHaveTextContent('1 organization has contributed data for Address'); + }); + test('renders promoted card when promotedContribution provided', () => { renderContributionsDrawer({ open: true, diff --git a/src/react/src/__tests__/components/ProductionLocationDetailsContainer.test.js b/src/react/src/__tests__/components/ProductionLocationDetailsContainer.test.js index a6052ce7f..069db714a 100644 --- a/src/react/src/__tests__/components/ProductionLocationDetailsContainer.test.js +++ b/src/react/src/__tests__/components/ProductionLocationDetailsContainer.test.js @@ -28,6 +28,12 @@ jest.mock( jest.mock('../../actions/facilities', () => ({ fetchSingleFacility: () => ({ type: 'noop' }), resetSingleFacility: () => ({ type: 'RESET_SINGLE_FACILITY' }), + fetchFacilities: () => () => {}, +})); + +jest.mock('../../actions/filters', () => ({ + setFiltersFromQueryString: () => ({ type: 'noop' }), + resetAllFilters: () => ({ type: 'RESET_ALL_FILTERS' }), })); jest.mock('../../actions/partnerFieldGroups', () => ({ diff --git a/src/react/src/__tests__/components/ProductionLocationDetailsMap.test.jsx b/src/react/src/__tests__/components/ProductionLocationDetailsMap.test.jsx new file mode 100644 index 000000000..562346a1a --- /dev/null +++ b/src/react/src/__tests__/components/ProductionLocationDetailsMap.test.jsx @@ -0,0 +1,274 @@ +import React from 'react'; +import { MemoryRouter, Route } from 'react-router-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; + +import ProductionLocationDetailsMap from '../../components/ProductionLocation/ProductionLocationDetailsMap/ProductionLocationDetailsMap'; + +jest.mock('leaflet', () => ({ icon: jest.fn(() => ({})) })); +jest.mock('leaflet/dist/leaflet.css', () => {}); + +jest.mock('react-leaflet', () => ({ + Map: ({ children }) =>
{children}
, + TileLayer: () => null, + Marker: () => null, +})); + +jest.mock('react-leaflet-control', () => ({ children }) => <>{children}); + +jest.mock('../../components/VectorTileFacilitiesLayer', () => ({ + __esModule: true, + default: () => null, + createMarkerIcon: () => ({}), +})); +jest.mock('../../components/VectorTileFacilityGridLayer', () => () => null); +jest.mock('../../components/VectorTileGridLegend', () => () => null); + +jest.mock( + '../../components/ProductionLocation/DataPoint/DataPoint', + () => ({ + __esModule: true, + default: ({ label, value, drawerData, onOpenDrawer, renderDrawer }) => { + const hasContributions = + Array.isArray(drawerData?.contributions) && + drawerData.contributions.length > 0 && + !!onOpenDrawer; + return ( +
+ {label} + {value} + {hasContributions && ( + + )} + {typeof renderDrawer === 'function' && renderDrawer()} +
+ ); + }, + }), +); + +jest.mock( + '../../components/ProductionLocation/ContributionsDrawer/ContributionsDrawer', + () => ({ + __esModule: true, + default: ({ open }) => + open ?
: null, + }), +); + +const FACILITY_ADDRESS = '123 Main St, New York'; +const FACILITY_LNG = -73.8314318; +const FACILITY_LAT = 40.762569; +const EXPECTED_COORD_DISPLAY = `${FACILITY_LAT}, ${FACILITY_LNG}`; + +const makeFacility = (overrides = {}) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [FACILITY_LNG, FACILITY_LAT], + }, + properties: { + address: FACILITY_ADDRESS, + other_locations: [], + created_from: { + contributor: 'Test Org', + created_at: '2023-01-01T00:00:00Z', + }, + extended_fields: { + address: [ + { + value: FACILITY_ADDRESS, + contributor_name: 'Test Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + ...overrides, +}); + +const makeStore = facilityData => + createStore(() => ({ + facilities: { singleFacility: { data: facilityData } }, + vectorTileLayer: { gridColorRamp: [] }, + })); + +const renderMap = (facilityData = null) => + render( + + + + + , + ); + +describe('ProductionLocationDetailsMap', () => { + describe('section header', () => { + it('renders the "Geographic information" section title', () => { + renderMap(makeFacility()); + + expect( + screen.getByText('Geographic information'), + ).toBeInTheDocument(); + }); + }); + + describe('info grid', () => { + it('renders the info grid container', () => { + renderMap(makeFacility()); + + expect( + screen.getByTestId('production-location-info-grid'), + ).toBeInTheDocument(); + }); + + it('renders an address row and a coordinates row', () => { + renderMap(makeFacility()); + + expect( + screen.getByTestId('production-location-address-row'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('production-location-coordinates-row'), + ).toBeInTheDocument(); + }); + }); + + describe('address DataPoint', () => { + it('renders the "Address" label', () => { + renderMap(makeFacility()); + + const addressRow = screen.getByTestId( + 'production-location-address-row', + ); + expect(addressRow).toHaveTextContent('Address'); + }); + + it('displays the facility address', () => { + renderMap(makeFacility()); + + const addressRow = screen.getByTestId( + 'production-location-address-row', + ); + expect(addressRow).toHaveTextContent(FACILITY_ADDRESS); + }); + + it('shows "—" when properties.address is empty', () => { + const facility = makeFacility(); + facility.properties.address = ''; + facility.properties.extended_fields.address = []; + renderMap(facility); + + const addressRow = screen.getByTestId( + 'production-location-address-row', + ); + expect(addressRow).toHaveTextContent('—'); + }); + }); + + describe('coordinates DataPoint', () => { + it('renders the "Coordinates" label', () => { + renderMap(makeFacility()); + + const coordRow = screen.getByTestId( + 'production-location-coordinates-row', + ); + expect(coordRow).toHaveTextContent('Coordinates'); + }); + + it('displays coordinates formatted as "lat, lng"', () => { + renderMap(makeFacility()); + + const coordRow = screen.getByTestId( + 'production-location-coordinates-row', + ); + expect(coordRow).toHaveTextContent(EXPECTED_COORD_DISPLAY); + }); + + it('shows "—" when geometry.coordinates is absent', () => { + const facility = makeFacility({ geometry: null }); + renderMap(facility); + + const coordRow = screen.getByTestId( + 'production-location-coordinates-row', + ); + expect(coordRow).toHaveTextContent('—'); + }); + }); + + describe('sources button and drawer', () => { + const facilityWithMultipleAddresses = makeFacility({ + properties: { + address: FACILITY_ADDRESS, + other_locations: [], + created_from: { + contributor: 'Test Org', + created_at: '2023-01-01T00:00:00Z', + }, + extended_fields: { + address: [ + { + value: FACILITY_ADDRESS, + contributor_name: 'Test Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: '456 Second Ave', + contributor_name: 'Second Org', + contributor_id: 2, + created_at: '2023-03-01T00:00:00Z', + updated_at: '2023-03-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + }); + + it('shows the sources button when there are address contributions', () => { + renderMap(facilityWithMultipleAddresses); + + expect( + screen.getByTestId('data-point-sources-button'), + ).toBeInTheDocument(); + }); + + it('does not show the sources button when there is only one address entry', () => { + renderMap(makeFacility()); + + expect( + screen.queryByTestId('data-point-sources-button'), + ).not.toBeInTheDocument(); + }); + + it('opens the ContributionsDrawer when the sources button is clicked', () => { + renderMap(facilityWithMultipleAddresses); + + expect( + screen.queryByTestId('contributions-drawer'), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('data-point-sources-button')); + + expect( + screen.getByTestId('contributions-drawer'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/react/src/__tests__/components/ProductionLocationDetailsMapUtils.test.js b/src/react/src/__tests__/components/ProductionLocationDetailsMapUtils.test.js new file mode 100644 index 000000000..d5246bbde --- /dev/null +++ b/src/react/src/__tests__/components/ProductionLocationDetailsMapUtils.test.js @@ -0,0 +1,574 @@ +import { + getContributorStatus, + getFieldContributorInfo, +} from '../../components/ProductionLocation/ProductionLocationDetailsMap/utils'; +import { + STATUS_CLAIMED, + STATUS_CROWDSOURCED, +} from '../../components/ProductionLocation/DataPoint/constants'; + +jest.mock('leaflet', () => ({ icon: jest.fn(() => ({})) })); + +const ADDR = 'address'; +const COORDS = 'coordinates'; + +describe('getContributorStatus', () => { + it('returns null when contributorName is falsy', () => { + expect(getContributorStatus('', false)).toBeNull(); + expect(getContributorStatus(null, false)).toBeNull(); + expect(getContributorStatus(undefined, true)).toBeNull(); + }); + + it('returns STATUS_CLAIMED when isFromClaim is true', () => { + expect(getContributorStatus('Test Org', true)).toBe(STATUS_CLAIMED); + }); + + it('returns STATUS_CROWDSOURCED when isFromClaim is false', () => { + expect(getContributorStatus('Test Org', false)).toBe(STATUS_CROWDSOURCED); + }); +}); + +describe('getFieldContributorInfo — ADDRESS', () => { + it('returns empty defaults when singleFacilityData is null', () => { + const result = getFieldContributorInfo(null, ADDR); + + expect(result.contributorName).toBe(''); + expect(result.userId).toBeNull(); + expect(result.date).toBe(''); + expect(result.status).toBeNull(); + expect(result.drawerData.promotedContribution).toBeNull(); + expect(result.drawerData.contributions).toEqual([]); + }); + + it('uses the field matching properties.address as the canonical contributor', () => { + const data = { + properties: { + address: '123 Main St', + extended_fields: { + address: [ + { + value: '123 Main St', + contributor_name: 'Canonical Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: '456 Other St', + contributor_name: 'Other Org', + contributor_id: 2, + created_at: '2023-02-01T00:00:00Z', + updated_at: '2023-02-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + expect(result.contributorName).toBe('Canonical Org'); + expect(result.userId).toBe(1); + expect(result.drawerData.promotedContribution.value).toBe('123 Main St'); + expect(result.drawerData.contributions).toHaveLength(1); + expect(result.drawerData.contributions[0].sourceName).toBe('Other Org'); + }); + + it('shows no attribution and surfaces all fields when no entry matches properties.address', () => { + // No extended_field value matches properties.address. Promoting + // uniqueAddressFields[0] arbitrarily would attribute the displayed + // address to a contributor who submitted a different value. Instead, + // canonicalField is null and all submissions appear in contributions. + const data = { + properties: { + address: 'No Match', + extended_fields: { + address: [ + { + value: 'First St', + contributor_name: 'First Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: 'Second St', + contributor_name: 'Second Org', + contributor_id: 2, + created_at: '2023-02-01T00:00:00Z', + updated_at: '2023-02-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + // No canonical match → no attribution on the displayed address row. + expect(result.contributorName).toBe(''); + expect(result.userId).toBeNull(); + expect(result.date).toBe(''); + expect(result.status).toBeNull(); + expect(result.drawerData.promotedContribution).toBeNull(); + // All extended_fields appear in the drawer. + expect(result.drawerData.contributions).toHaveLength(2); + expect( + result.drawerData.contributions.map(c => c.sourceName), + ).toEqual(['First Org', 'Second Org']); + }); + + it('deduplicates entries with identical value + created_at + contributor_name', () => { + const entry = { + value: '123 Main St', + contributor_name: 'Test Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }; + const data = { + properties: { + address: 'Other', + extended_fields: { + address: [entry, { ...entry }, entry], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + // 3 identical raw entries → deduped to 1. properties.address ('Other') + // doesn't match the entry's value ('123 Main St'), so no canonical + // field is promoted. The 1 deduplicated entry appears in contributions. + expect(result.drawerData.promotedContribution).toBeNull(); + expect(result.drawerData.contributions).toHaveLength(1); + }); + + it('keeps entries separate when value+contributor match but created_at differs', () => { + // entry1 → canonical (matches properties.address) + // entry2 → contribution: 'Other St', 'Other Org', date A + // entry3 → contribution: 'Other St', 'Other Org', same value+org, date B + // + // Without created_at in the dedup key entry2 and entry3 collapse to one + // contribution. With it they remain distinct, giving length 2. + const data = { + properties: { + address: '123 Main St', + extended_fields: { + address: [ + { + value: '123 Main St', + contributor_name: 'Canonical Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: 'Other St', + contributor_name: 'Other Org', + contributor_id: 2, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: 'Other St', + contributor_name: 'Other Org', + contributor_id: 2, + created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-06-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + expect(result.drawerData.contributions).toHaveLength(2); + }); + + it('includes extra canonical entries (same address) in contributions', () => { + // Two different contributors both report the canonical address. + // Only the first becomes the promoted entry; the second must appear + // in contributions rather than being silently discarded. + const data = { + properties: { + address: '123 Main St', + extended_fields: { + address: [ + { + value: '123 Main St', + contributor_name: 'Org A', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: false, + }, + { + value: '123 Main St', + contributor_name: 'Org B', + contributor_id: 2, + created_at: '2023-02-01T00:00:00Z', + updated_at: '2023-02-01T00:00:00Z', + is_from_claim: false, + }, + { + value: 'Other St', + contributor_name: 'Org C', + contributor_id: 3, + created_at: '2023-03-01T00:00:00Z', + updated_at: '2023-03-01T00:00:00Z', + is_from_claim: false, + }, + ], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + expect(result.contributorName).toBe('Org A'); + // Org B (second canonical) + Org C (non-canonical) = 2 contributions + expect(result.drawerData.contributions).toHaveLength(2); + expect( + result.drawerData.contributions.map(c => c.sourceName), + ).toEqual(['Org B', 'Org C']); + }); + + it('sets STATUS_CLAIMED when the canonical field is_from_claim is true', () => { + const data = { + properties: { + address: '123 Main St', + extended_fields: { + address: [ + { + value: '123 Main St', + contributor_name: 'Claimed Org', + contributor_id: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + is_from_claim: true, + }, + ], + }, + }, + }; + + const result = getFieldContributorInfo(data, ADDR); + + expect(result.status).toBe(STATUS_CLAIMED); + }); +}); + +describe('getFieldContributorInfo — COORDINATES', () => { + it('returns empty defaults when singleFacilityData is null', () => { + const result = getFieldContributorInfo(null, COORDS); + + expect(result.contributorName).toBe(''); + expect(result.userId).toBeNull(); + expect(result.date).toBe(''); + expect(result.status).toBeNull(); + expect(result.drawerData.contributions).toEqual([]); + }); + + it('falls back to created_from.contributor when no canonical other_location', () => { + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.contributorName).toBe('Origin Org'); + }); + + it('identifies canonical location by matching lat/lng', () => { + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: 40.7, + lng: -73.8, + contributor_name: 'Canonical Org', + contributor_id: 10, + is_from_claim: false, + has_invalid_location: false, + }, + { + lat: 51.0, + lng: 0.0, + contributor_name: 'Other Org', + contributor_id: 20, + is_from_claim: false, + has_invalid_location: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.contributorName).toBe('Canonical Org'); + expect(result.drawerData.contributions).toHaveLength(1); + expect(result.drawerData.contributions[0].sourceName).toBe('Other Org'); + }); + + it('identifies a claim as canonical when its coordinates match the geometry', () => { + // Claim whose lat/lng match geometry.coordinates → canonical. + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: 40.7, + lng: -73.8, + contributor_name: 'Claimed Org', + contributor_id: 10, + is_from_claim: true, + has_invalid_location: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.contributorName).toBe('Claimed Org'); + expect(result.status).toBe(STATUS_CLAIMED); + }); + + it('does not treat a claim as canonical when its coordinates differ from the geometry', () => { + // Claim lat/lng differ from geometry.coordinates (e.g. after an admin + // location correction post-claim-approval). The claim must fall to + // contributions and provenance falls back to created_from. + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: 51.0, + lng: 0.0, + contributor_name: 'Claimed Org', + contributor_id: 10, + is_from_claim: true, + has_invalid_location: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + // Claim is not canonical → fall back to created_from attribution. + expect(result.contributorName).toBe('Origin Org'); + // The mismatched claim still appears as a contribution. + expect(result.drawerData.contributions).toHaveLength(1); + expect(result.drawerData.contributions[0].sourceName).toBe( + 'Claimed Org', + ); + }); + + it('filters has_invalid_location entries from contributions', () => { + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: 51.0, + lng: 0.0, + contributor_name: 'Valid Org', + contributor_id: 1, + is_from_claim: false, + has_invalid_location: false, + }, + { + lat: 90.0, + lng: 0.0, + contributor_name: 'Invalid Org', + contributor_id: 2, + is_from_claim: false, + has_invalid_location: true, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.drawerData.contributions).toHaveLength(1); + expect(result.drawerData.contributions[0].sourceName).toBe('Valid Org'); + }); + + it('includes non-matching other_locations in contributions', () => { + // One entry matches geometry.coordinates → canonical. + // The remaining entries (claim and non-claim with different lat/lng) + // must appear in contributions rather than being dropped. + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: 40.7, + lng: -73.8, + contributor_name: 'Matching Org', + contributor_id: 10, + is_from_claim: true, + has_invalid_location: false, + }, + { + lat: 52.0, + lng: 1.0, + contributor_name: 'Other Claim', + contributor_id: 20, + is_from_claim: true, + has_invalid_location: false, + }, + { + lat: 53.0, + lng: 2.0, + contributor_name: 'Non-claim Org', + contributor_id: 30, + is_from_claim: false, + has_invalid_location: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.contributorName).toBe('Matching Org'); + // Other Claim + Non-claim Org = 2 contributions + expect(result.drawerData.contributions).toHaveLength(2); + expect( + result.drawerData.contributions.map(c => c.sourceName), + ).toEqual(['Other Claim', 'Non-claim Org']); + }); + + it('does not treat a null-coordinate entry as canonical when facility also has null coordinates', () => { + const data = { + geometry: null, + properties: { + other_locations: [ + { + lat: null, + lng: null, + contributor_name: 'Null Coord Org', + contributor_id: 1, + is_from_claim: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.contributorName).toBe('Origin Org'); + expect(result.drawerData.contributions).toHaveLength(0); + }); + + it('excludes null-coordinate entries from contributions even when has_invalid_location is absent', () => { + const data = { + geometry: { coordinates: [-73.8, 40.7] }, + properties: { + other_locations: [ + { + lat: null, + lng: null, + contributor_name: 'Null Coord Org', + contributor_id: 1, + is_from_claim: false, + }, + { + lat: 51.0, + lng: 0.0, + contributor_name: 'Valid Org', + contributor_id: 2, + is_from_claim: false, + has_invalid_location: false, + }, + ], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.drawerData.contributions).toHaveLength(1); + expect(result.drawerData.contributions[0].sourceName).toBe('Valid Org'); + }); + + it('formats the promoted coordinate value as "lat, lng"', () => { + const data = { + geometry: { coordinates: [-73.8314318, 40.762569] }, + properties: { + other_locations: [], + created_from: { + contributor: 'Origin Org', + created_at: '2023-01-01T00:00:00Z', + }, + }, + }; + + const result = getFieldContributorInfo(data, COORDS); + + expect(result.drawerData.promotedContribution.value).toBe( + '40.762569, -73.8314318', + ); + }); +}); + +describe('getFieldContributorInfo — default', () => { + it('returns empty defaults for an unknown field type', () => { + const result = getFieldContributorInfo({}, 'unknown_type'); + + expect(result.contributorName).toBe(''); + expect(result.userId).toBeNull(); + expect(result.date).toBe(''); + expect(result.status).toBeNull(); + expect(result.drawerData.promotedContribution).toBeNull(); + expect(result.drawerData.contributions).toEqual([]); + }); +}); diff --git a/src/react/src/components/Map.jsx b/src/react/src/components/Map.jsx index 1ec8d731a..3cb87281e 100644 --- a/src/react/src/components/Map.jsx +++ b/src/react/src/components/Map.jsx @@ -116,6 +116,11 @@ class Map extends Component { return renderDetailRoute(); }} /> + renderDetailRoute()} + /> { const contributionsCount = getContributionsCount(contributions); - const contributorCount = getContributorCount([ - ...contributions, - promotedContribution, - ]); + const contributorCount = getContributorCount( + promotedContribution + ? [...contributions, promotedContribution] + : contributions, + ); const sectionLabel = getContributionsSectionLabel(contributions); return ( diff --git a/src/react/src/components/ProductionLocation/Heading/ClaimFlag/utils.js b/src/react/src/components/ProductionLocation/Heading/ClaimFlag/utils.js index f58f63754..a08009ca4 100644 --- a/src/react/src/components/ProductionLocation/Heading/ClaimFlag/utils.js +++ b/src/react/src/components/ProductionLocation/Heading/ClaimFlag/utils.js @@ -24,7 +24,8 @@ export const getMainText = (isClaimed, isPending) => { }; export const formatClaimDate = date => { - if (date == null || date === '') return null; - if (!moment(date).isValid()) return null; - return moment(date).format('LL'); + if (!date) return null; + const parsedDate = moment(date); + if (!parsedDate.isValid()) return null; + return parsedDate.format('LL'); }; diff --git a/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx b/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx index 93bcb08b7..ff988e477 100644 --- a/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx +++ b/src/react/src/components/ProductionLocation/Heading/DataSourcesInfo/DataSourcesInfo.jsx @@ -59,7 +59,7 @@ const ProductionLocationDetailsDataSourcesInfo = ({ classes, className }) => { />
- + {DATA_SOURCES_ITEMS.map(item => ( { fetchFacility(normalizedOsID, contributors); - }, [normalizedOsID, contributors, fetchFacility]); + }, [normalizedOsID]); useEffect(() => { if (!partnerFieldGroupsData) { getPartnerFieldGroups(); } - }, [getPartnerFieldGroups, partnerFieldGroupsData]); + }, [partnerFieldGroupsData, getPartnerFieldGroups]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const search = location?.search || ''; + if (search) { + hydrateFiltersFromQueryString(search); + } else { + resetFilters(); + } + fetchFacilitiesForMap(); + }, [location?.search]); - // Run cleanup only on unmount. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => () => clearFacility(), []); - if (fetching) { + const requestedId = normalizedOsID || ''; + const loadedId = data?.id || ''; + const isStaleData = + requestedId && + loadedId && + requestedId.toLowerCase() !== loadedId.toLowerCase(); + + if (fetching || (isStaleData && !error?.length)) { return (
@@ -84,7 +111,9 @@ function ProductionLocationDetailsContainer({ ); } - if (data?.id && data?.id !== osID) { + const isSameFacility = requestedId.toLowerCase() === loadedId.toLowerCase(); + const needsCanonicalRedirect = isSameFacility && requestedId !== loadedId; + if (data?.id && needsCanonicalRedirect) { return ( ({ }, clearFacility: () => dispatch(resetSingleFacility()), getPartnerFieldGroups: () => dispatch(fetchPartnerFieldGroups()), + hydrateFiltersFromQueryString: qs => + dispatch(setFiltersFromQueryString(qs)), + resetFilters: () => dispatch(resetAllFilters()), + fetchFacilitiesForMap: () => + dispatch( + fetchFacilities({ + pushNewRoute: noop, + activateFacilitiesTab: false, + }), + ), }); export default withRouter( diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/ProductionLocationDetailsMap.jsx b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/ProductionLocationDetailsMap.jsx index 3b509f81f..c8c2683f2 100644 --- a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/ProductionLocationDetailsMap.jsx +++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/ProductionLocationDetailsMap.jsx @@ -1,20 +1,406 @@ -import React from 'react'; +import React, { useMemo, useRef, useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import Button from '@material-ui/core/Button'; +import { Map as ReactLeafletMap, TileLayer, Marker } from 'react-leaflet'; +import Control from 'react-leaflet-control'; +import AddIcon from '@material-ui/icons/Add'; +import RemoveIcon from '@material-ui/icons/Remove'; +import MyLocationIcon from '@material-ui/icons/MyLocation'; +import LaunchIcon from '@material-ui/icons/Launch'; +import InfoOutlined from '@material-ui/icons/InfoOutlined'; +import get from 'lodash/get'; import { withStyles } from '@material-ui/core/styles'; -import Map from '../../Map'; +import 'leaflet/dist/leaflet.css'; + +import VectorTileFacilitiesLayer, { + createMarkerIcon, +} from '../../VectorTileFacilitiesLayer'; +import VectorTileFacilityGridLayer from '../../VectorTileFacilityGridLayer'; +import VectorTileGridLegend from '../../VectorTileGridLegend'; import productionLocationDetailsMapStyles from './styles'; +import { + SATELLITE_TILE_URL, + SATELLITE_TILE_ATTRIBUTION, + mapContainerStyles, + GEOGRAPHIC_INFORMATION_TOOLTIP, +} from './constants'; +import { + detailsZoomLevel, + initialCenter, + initialZoom, + maxVectorTileFacilitiesGridZoom, +} from '../../../util/constants.facilitiesMap'; +import { + productionLocationDetailsRoute, + SelectedMarkerColor, +} from '../../../util/constants'; -const ProductionLocationDetailsMap = ({ classes }) => ( -
- - Interactive map - - -
-); +import GeneralInformation from '../../Icons/GeneralInformation'; +import IconComponent from '../../Shared/IconComponent/IconComponent'; +import DataPoint from '../DataPoint/DataPoint'; +import ContributionsDrawer from '../ContributionsDrawer/ContributionsDrawer'; +import { FIELD_TYPE, getFieldContributorInfo } from './utils'; + +/** + * Production location detail map: satellite base layer, zoom/center controls, + * vector-tile facilities (markers when zoomed in, circles with count when zoomed out), + * and Open in Google Maps. Clicking other locations navigates or shows a popup list + * when multiple facilities share the same point. + */ +function ProductionLocationDetailsMap({ + classes, + singleFacilityData, + gridColorRamp, + history: { push }, + match: { params: { osID } = {} } = {}, +}) { + const mapRef = useRef(null); + + const coordinates = get(singleFacilityData, 'geometry.coordinates', null); + const hasCoordinates = + Array.isArray(coordinates) && coordinates.length >= 2; + + const [currentMapZoomLevel, setCurrentMapZoomLevel] = useState( + hasCoordinates ? detailsZoomLevel : initialZoom, + ); + const [addressDrawerOpen, setAddressDrawerOpen] = useState(false); + const [coordinatesDrawerOpen, setCoordinatesDrawerOpen] = useState(false); + + const address = get(singleFacilityData, 'properties.address', '') || ''; + const coordinatesDisplay = hasCoordinates + ? `${coordinates[1]}, ${coordinates[0]}` + : ''; + + const { + contributorName: addressContributorName, + userId: addressUserId, + date: addressDate, + status: addressStatus, + drawerData: addressDrawerData, + } = useMemo( + () => getFieldContributorInfo(singleFacilityData, FIELD_TYPE.ADDRESS), + [singleFacilityData], + ); + + const { + contributorName: coordinatesContributorName, + userId: coordinatesUserId, + date: coordinatesDate, + status: coordinatesStatus, + drawerData: coordinatesDrawerData, + } = useMemo( + () => + getFieldContributorInfo(singleFacilityData, FIELD_TYPE.COORDINATES), + [singleFacilityData], + ); + + const center = useMemo(() => { + if (Array.isArray(coordinates) && coordinates.length >= 2) { + return [coordinates[1], coordinates[0]]; + } + return [initialCenter.lat, initialCenter.lng]; + }, [coordinates]); + + const selectedMarkerIcon = useMemo( + () => createMarkerIcon(SelectedMarkerColor), + [], + ); + + const zoom = hasCoordinates ? detailsZoomLevel : initialZoom; + + const handleCenterOnLocation = useCallback(() => { + const map = mapRef.current?.leafletElement; + if (map && center) { + map.setView(center, zoom); + } + }, [center, zoom]); + + const handleZoomIn = useCallback(() => { + const map = mapRef.current?.leafletElement; + if (map) map.zoomIn(); + }, []); + + const handleZoomOut = useCallback(() => { + const map = mapRef.current?.leafletElement; + if (map) map.zoomOut(); + }, []); + + const googleMapsUrl = useMemo(() => { + if (!hasCoordinates) return null; + const [lat, lng] = center; + return `https://www.google.com/maps?q=${lat},${lng}`; + }, [hasCoordinates, center]); + + const handleMarkerClick = useCallback( + e => { + const id = get(e, 'layer.properties.id', null); + if (id) { + push(productionLocationDetailsRoute.replace(':osID', id)); + } + }, + [push], + ); + + const handleFacilityClick = useCallback( + id => { + if (id) { + push(productionLocationDetailsRoute.replace(':osID', id)); + } + }, + [push], + ); + + const handleCellClick = useCallback(event => { + const { xmin, ymin, xmax, ymax, count } = get( + event, + 'layer.properties', + {}, + ); + const leafletMap = mapRef.current?.leafletElement; + if (count && leafletMap) { + leafletMap.fitBounds([ + [ymin, xmin], + [ymax, xmax], + ]); + } + }, []); + + const handleZoomEnd = useCallback(e => { + const newZoom = get(e, 'target._zoom', null); + if (typeof newZoom === 'number') { + setCurrentMapZoomLevel(newZoom); + } + }, []); + + return ( +
+
+ + + Geographic information + + +
+
+
+ + + +
+ + + + + + + + + +
+
+ {googleMapsUrl && ( + + + + )} + + + + + + Drag to pan + + + + {hasCoordinates && ( + + )} + {currentMapZoomLevel <= + maxVectorTileFacilitiesGridZoom && ( + + )} +
+
+
+
+
+ setAddressDrawerOpen(true)} + renderDrawer={() => ( + setAddressDrawerOpen(false)} + fieldName="Address" + promotedContribution={ + addressDrawerData.promotedContribution + } + contributions={addressDrawerData.contributions} + /> + )} + /> +
+
+ setCoordinatesDrawerOpen(true)} + renderDrawer={() => ( + setCoordinatesDrawerOpen(false)} + fieldName="Coordinates" + promotedContribution={ + coordinatesDrawerData.promotedContribution + } + contributions={ + coordinatesDrawerData.contributions + } + /> + )} + /> +
+
+
+ ); +} + +ProductionLocationDetailsMap.propTypes = { + classes: PropTypes.object.isRequired, + singleFacilityData: PropTypes.object, + gridColorRamp: PropTypes.arrayOf(PropTypes.array), + history: PropTypes.shape({ push: PropTypes.func.isRequired }).isRequired, + match: PropTypes.shape({ + params: PropTypes.shape({ osID: PropTypes.string }), + }).isRequired, +}; + +ProductionLocationDetailsMap.defaultProps = { + singleFacilityData: null, + gridColorRamp: [], +}; + +function mapStateToProps({ + facilities: { + singleFacility: { data: singleFacilityData }, + }, + vectorTileLayer: { gridColorRamp }, +}) { + return { + singleFacilityData: singleFacilityData || null, + gridColorRamp: gridColorRamp || [], + }; +} -export default withStyles(productionLocationDetailsMapStyles)( - ProductionLocationDetailsMap, +export default withRouter( + connect(mapStateToProps)( + withStyles(productionLocationDetailsMapStyles)( + ProductionLocationDetailsMap, + ), + ), ); diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/constants.js b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/constants.js new file mode 100644 index 000000000..3a4086f70 --- /dev/null +++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/constants.js @@ -0,0 +1,17 @@ +export const SATELLITE_TILE_URL = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; +export const SATELLITE_TILE_ATTRIBUTION = + '© Esri, Maxar, Earthstar Geographics, and the GIS User Community'; + +export const mapContainerStyles = Object.freeze({ + height: '100%', + width: '100%', +}); + +export const FIELD_TYPE = Object.freeze({ + ADDRESS: 'address', + COORDINATES: 'coordinates', +}); + +export const GEOGRAPHIC_INFORMATION_TOOLTIP = + 'Physical address and geographic coordinates for this production location.'; diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/styles.js b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/styles.js index a5e5bf4eb..8a979dfb4 100644 --- a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/styles.js +++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/styles.js @@ -1,9 +1,111 @@ -export default theme => - Object.freeze({ - container: { - backgroundColor: 'white', - }, - title: Object.freeze({ +import { getTypographyStyles } from '../../../util/typographyStyles'; +import commonStyles from '../commonStyles'; +import COLOURS from '../../../util/COLOURS'; + +export default theme => { + const typography = getTypographyStyles(theme); + return Object.freeze({ + container: Object.freeze({ + ...commonStyles(theme).container, + padding: '20px', + }), + title: { marginBottom: theme.spacing.unit, + }, + sectionTitleRow: Object.freeze({ + display: 'flex', + alignItems: 'center', + gap: '4px', + borderBottom: `1px solid ${theme.palette.divider}`, + paddingBottom: theme.spacing.unit * 1.5, + marginBottom: theme.spacing.unit * 2, + }), + sectionTitle: Object.freeze({ + ...typography.sectionTitle, + marginTop: 0, + marginBottom: 0, + marginRight: 0, + }), + sectionTitleIcon: Object.freeze({ + color: theme.palette.text.secondary, + flexShrink: 0, + }), + sectionTitleInfoButton: Object.freeze({ + padding: theme.spacing.unit * 0.5, + color: theme.palette.text.secondary, + '&:hover': { + color: theme.palette.text.primary, + backgroundColor: theme.palette.action.hover, + }, + }), + mapContainer: Object.freeze({ + width: 'auto', + maxWidth: '100%', + height: '320px', + [theme.breakpoints.down('md')]: { + height: '380px', + }, + }), + mapInner: Object.freeze({ + height: '100%', + width: '100%', + }), + mapControlsRow: Object.freeze({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + '& > * + *': { + marginTop: theme.spacing.unit, + }, + }), + mapControlButton: Object.freeze({ + width: 34, + height: 34, + minWidth: 34, + minHeight: 34, + padding: 0, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[2], + color: theme.palette.text.secondary, + '&:hover': { + backgroundColor: theme.palette.grey[100], + }, + }), + googleMapsButton: Object.freeze({ + ...typography.bodyText, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[2], + color: theme.palette.text.secondary, + textTransform: 'none', + borderColor: theme.palette.grey[400], + '&, & .MuiButton-label, & svg': { + color: theme.palette.text.secondary, + }, + '&:hover': { + backgroundColor: theme.palette.grey[100], + borderColor: theme.palette.grey[500], + '&, & .MuiButton-label, & svg': { + color: theme.palette.text.secondary, + }, + }, + }), + googleMapsButtonIcon: Object.freeze({ + marginRight: theme.spacing.unit, + }), + mapDragHint: Object.freeze({ + display: 'inline-block', + paddingTop: '0.25rem', + paddingBottom: '0.35rem', + paddingLeft: theme.spacing.unit, + paddingRight: theme.spacing.unit, + fontSize: '0.875rem', + lineHeight: 1, + color: COLOURS.WHITE, + backgroundColor: '#00000080', + cursor: 'grab', + }), + infoGrid: Object.freeze({ + marginTop: theme.spacing.unit * 2, }), }); +}; diff --git a/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/utils.js b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/utils.js new file mode 100644 index 000000000..5974b2239 --- /dev/null +++ b/src/react/src/components/ProductionLocation/ProductionLocationDetailsMap/utils.js @@ -0,0 +1,209 @@ +import get from 'lodash/get'; +import head from 'lodash/head'; +import partition from 'lodash/partition'; +import uniqBy from 'lodash/uniqBy'; + +import { STATUS_CLAIMED, STATUS_CROWDSOURCED } from '../DataPoint/constants'; +import { FIELD_TYPE } from './constants'; + +export { FIELD_TYPE }; + +export const getContributorStatus = (contributorName, isFromClaim) => { + if (!contributorName) return null; + return isFromClaim ? STATUS_CLAIMED : STATUS_CROWDSOURCED; +}; + +/** + * @param {Object} singleFacilityData + * @param {string} fieldType + * @returns {{ + * contributorName: string, + * userId: number|string|null, + * date: string, + * status: string|null, + * drawerData: object + * }} + */ +export const getFieldContributorInfo = (singleFacilityData, fieldType) => { + switch (fieldType) { + case FIELD_TYPE.ADDRESS: { + const address = + get(singleFacilityData, 'properties.address', '') || ''; + const addressFields = get( + singleFacilityData, + 'properties.extended_fields.address', + [], + ); + + // Same-contributor same-address + // entries from different dates remain distinct. + const uniqueAddressFields = uniqBy( + addressFields, + f => + (get(f, 'value', '') || '') + + (get(f, 'created_at', '') || '') + + (get(f, 'contributor_name', '') || ''), + ); + + const [canonicalFields, otherFields] = partition( + uniqueAddressFields, + f => f.value === address, + ); + // Do not fall back to an arbitrary entry when no field matches the + // canonical address: that would attribute provenance to a + // contributor who submitted a different address value entirely. + const canonicalField = canonicalFields[0] || null; + // When there is no canonical match, surface every known submission + // in the drawer (no promoted contribution, all fields listed). + const contributions = canonicalField + ? [...canonicalFields.slice(1), ...otherFields] + : uniqueAddressFields; + + const contributorName = + get(canonicalField, 'contributor_name', '') || ''; + const userId = get(canonicalField, 'contributor_id', null); + const date = + get(canonicalField, 'created_at', '') || + get(canonicalField, 'updated_at', '') || + ''; + const status = getContributorStatus( + contributorName, + get(canonicalField, 'is_from_claim', false), + ); + + const promotedContribution = canonicalField + ? { + value: get(canonicalField, 'value', '') || '', + sourceName: contributorName, + date: + get(canonicalField, 'created_at', '') || + get(canonicalField, 'updated_at', '') || + '', + userId, + } + : null; + + const drawerData = { + promotedContribution, + contributions: contributions.map(field => ({ + value: get(field, 'value', '') || '', + sourceName: get(field, 'contributor_name', '') || '', + date: + get(field, 'created_at', '') || + get(field, 'updated_at', '') || + '', + userId: get(field, 'contributor_id', null), + })), + }; + + return { contributorName, userId, date, status, drawerData }; + } + + case FIELD_TYPE.COORDINATES: { + const coordinates = get( + singleFacilityData, + 'geometry.coordinates', + null, + ); + const facilityLng = Array.isArray(coordinates) + ? coordinates[0] + : null; + const facilityLat = Array.isArray(coordinates) + ? coordinates[1] + : null; + const otherLocations = get( + singleFacilityData, + 'properties.other_locations', + [], + ); + // An entry is canonical only when its coordinates match the point + // actually rendered on the map (geometry.coordinates). Using + // is_from_claim alone as a canonical signal would attribute + // provenance to a different coordinate when the claim's lat/lng + // diverges from the displayed geometry (e.g. after an admin + // location correction post-claim-approval). + const COORD_EPSILON = 1e-10; + const [canonicalLocations, nonCanonicalLocations] = partition( + otherLocations, + ({ lng, lat, has_invalid_location: hasInvalidLocation }) => + facilityLat != null && + facilityLng != null && + lat != null && + lng != null && + !hasInvalidLocation && + Math.abs(lng - facilityLng) < COORD_EPSILON && + Math.abs(lat - facilityLat) < COORD_EPSILON, + ); + const canonicalLocation = head(canonicalLocations); + + // Prefer the contributor from the canonical other_location entry + // (covers claims and admin location corrections). Fall back to + // created_from.contributor for the common case where the primary + // coordinates came directly from the original list-item geocoding. + const locationContributorName = + get(canonicalLocation, 'contributor_name', '') || ''; + const contributorName = + locationContributorName || + get( + singleFacilityData, + 'properties.created_from.contributor', + '', + ) || + ''; + const userId = get(canonicalLocation, 'contributor_id', null); + const date = ''; + const status = getContributorStatus( + contributorName, + get(canonicalLocation, 'is_from_claim', false), + ); + + const primaryCoordValue = + Array.isArray(coordinates) && coordinates.length >= 2 + ? `${coordinates[1]}, ${coordinates[0]}` + : ''; + const promotedValue = canonicalLocation + ? `${canonicalLocation.lat}, ${canonicalLocation.lng}` + : primaryCoordValue; + + const promotedContribution = { + value: promotedValue, + sourceName: contributorName, + date, + userId, + }; + + // Include remaining canonical locations (slice(1)) so that + // additional claims/corrections are not silently discarded. + const hasValidCoords = item => item.lat != null && item.lng != null; + const contributions = [ + ...canonicalLocations + .slice(1) + .filter( + item => + !item.has_invalid_location && hasValidCoords(item), + ), + ...nonCanonicalLocations.filter( + item => !item.has_invalid_location && hasValidCoords(item), + ), + ].map(item => ({ + value: `${item.lat}, ${item.lng}`, + sourceName: get(item, 'contributor_name', '') || '', + date: '', + userId: get(item, 'contributor_id', null), + })); + + const drawerData = { promotedContribution, contributions }; + + return { contributorName, userId, date, status, drawerData }; + } + + default: + return { + contributorName: '', + userId: null, + date: '', + status: null, + drawerData: { promotedContribution: null, contributions: [] }, + }; + } +}; diff --git a/src/react/src/components/VectorTileFacilitiesLayer.jsx b/src/react/src/components/VectorTileFacilitiesLayer.jsx index 773f90ce0..66b0d3fac 100644 --- a/src/react/src/components/VectorTileFacilitiesLayer.jsx +++ b/src/react/src/components/VectorTileFacilitiesLayer.jsx @@ -23,7 +23,7 @@ import { const VectorGrid = withLeaflet(VectorGridDefault); -const createMarkerIcon = (color = '#838BA5') => { +export const createMarkerIcon = (color = '#838BA5') => { const fill = color.replace('#', '%23'); return L.icon({ iconUrl: `data:image/svg+xml;utf8,`, diff --git a/src/react/src/components/VectorTileGridLegend.jsx b/src/react/src/components/VectorTileGridLegend.jsx index 0da3f6eb3..0e5423dcc 100644 --- a/src/react/src/components/VectorTileGridLegend.jsx +++ b/src/react/src/components/VectorTileGridLegend.jsx @@ -45,7 +45,12 @@ const legendStyles = Object.freeze({ }), }); -function VectorTileGridLegend({ currentZoomLevel, gridColorRamp, classes }) { +function VectorTileGridLegend({ + currentZoomLevel, + gridColorRamp, + classes, + label = '# facilities', +}) { if ( !currentZoomLevel || currentZoomLevel > maxVectorTileFacilitiesGridZoom @@ -69,7 +74,7 @@ function VectorTileGridLegend({ currentZoomLevel, gridColorRamp, classes }) { style={legendStyles.legendStyle} className={classes.legendStyle} > -
# facilities
+
{label}
{gridColorRamp.map((colorDef, i, a) => legendCell(colorDef[1], 20 - 2 * (a.length - 1 - i)), diff --git a/src/react/src/setupTests.js b/src/react/src/setupTests.js index a57d0a77a..a66eeaa63 100644 --- a/src/react/src/setupTests.js +++ b/src/react/src/setupTests.js @@ -1,5 +1,20 @@ -import '@testing-library/jest-dom'; -import { JSDOM } from 'jsdom'; +// Node 14 does not expose TextEncoder/TextDecoder as globals; jsdom 18 +// requires them. Polyfill before jsdom is loaded. Must use require() here +// because ES `import` statements are hoisted above any code by Babel, which +// would make the polyfill arrive too late. +/* eslint-disable global-require */ +const { + TextEncoder: NodeTextEncoder, + TextDecoder: NodeTextDecoder, +} = require('util'); + +global.TextEncoder = global.TextEncoder || NodeTextEncoder; +global.TextDecoder = global.TextDecoder || NodeTextDecoder; + +require('@testing-library/jest-dom'); + +const { JSDOM } = require('jsdom'); +/* eslint-enable global-require */ const customJestEnvironment = async () => { const jsdom = new JSDOM('', {