Repository: dimagi/commcare-hq
Pull request #35953: Campaign Dashboard Map Widget UI
Author: login: zandre-eng
Labels: name: product/feature-flag
description: Change will only affect users who have a specific feature flag enabled
Created: 2025-03-12T13:49:16Z | Merged: 2025-03-17T08:09:48Z

## Product Description
<!-- Where applicable, describe user-facing effects and include screenshots. -->
Campaign map widgets can now be viewed on the Campaign page. Each widget consists of a simple filter section on the left, with a map comprised of case markers on the right
![Screenshot from 2025-03-12 15-43-12](https://github.com/user-attachments/assets/e681a1d2-a67a-4972-a341-dec948326a85)


## Technical Summary
<!--
    Provide a link to any tickets, design documents, and/or technical specifications
    associated with this change. Describe the rationale and design decisions.
-->
Link to ticket [here](https://dimagi.atlassian.net/browse/SC-4292).

This PR builds off the work done in @kaapstorm's PR (#35945).

The UI component for the map widget has been implemented in this ticket. When loading the campaign page, all created map widgets for that domain will have their configs sent to the front-end, organized by tab and sorted by display order.

On the front-end, a new HTML template is used to render a map widget according to its given config. This new template makes use of a filter panel partial to handle rendering its filters.

## Feature Flag
<!-- If this is specific to a feature flag, which one? -->
`CAMPAIGN_DASHBOARD`

## Safety Assurance

### Safety story
<!--
Describe how you became confident in this change, such as
local testing, why the change is inherently safe, and/or plans to limit the blast radius of a defect.

In particular consider how existing data may be impacted by this change.
-->
- Local testing done

These code changes reside behind a currently unused feature flag.

### Automated test coverage

<!-- Identify the related test coverage and the tests it would catch -->
Unit tests have been created to test the new paginated cases endpoint.

### QA Plan

<!--
- Describe QA plan that along with automated test coverages proves this PR is regression free
- Link to QA Ticket
-->
No QA planned.

### Rollback instructions

<!--
If this PR follows standards of revertability, check the box below.
Otherwise replace it with detailed instructions or reasons a rollback is impossible.
-->

- [X] This PR can be reverted after deploy with no further considerations

### Labels & Review
- [X] Risk label is set correctly
- [X] The set of people pinged as reviewers is appropriate for the level of risk of the change



Diff:
diff --git a/corehq/apps/campaign/static/campaign/js/dashboard.js b/corehq/apps/campaign/static/campaign/js/dashboard.js
new file mode 100644
index 000000000000..68a6928dba03
--- /dev/null
+++ b/corehq/apps/campaign/static/campaign/js/dashboard.js
@@ -0,0 +1,106 @@
+import "commcarehq";
+import "hqwebapp/js/htmx_and_alpine";
+import 'reports/js/bootstrap5/base';
+import $ from 'jquery';
+import initialPageData from "hqwebapp/js/initial_page_data";
+import { Map, MapItem } from "geospatial/js/models";
+
+
+let mobileWorkerMapsInitialized = false;
+
+$(function () {
+    // Only init case map widgets since this is the default tab
+    const mapWidgetConfigs = initialPageData.get('map_widgets');
+    for (const mapWidgetConfig of mapWidgetConfigs.cases) {
+        const mapWidget = new MapWidget(mapWidgetConfig);
+        mapWidget.initializeMap();
+    }
+    $('a[data-bs-toggle="tab"]').on('shown.bs.tab', tabSwitch);
+});
+
+function tabSwitch(e) {
+    const tabContentId = $(e.target).attr('href');
+
+    // Only load mobile worker map widgets when tab is clicked to prevent weird map sizing behaviour
+    if (!mobileWorkerMapsInitialized && tabContentId === '#mobile-workers-tab-content') {
+        mobileWorkerMapsInitialized = true;
+        const mapWidgetConfigs = initialPageData.get('map_widgets');
+        for (const mapWidgetConfig of mapWidgetConfigs.mobile_workers) {
+            const mapWidget = new MapWidget(mapWidgetConfig);
+            mapWidget.initializeMap();
+        }
+    }
+}
+
+var MapWidget = function (mapWidgetConfig) {
+    let self = this;
+    self.id = mapWidgetConfig.id;
+    self.caseType = mapWidgetConfig.case_type;
+    self.gpsPropName = mapWidgetConfig.geo_case_property;
+    self.title = mapWidgetConfig.title;
+
+    self.mapInstance;
+
+    self.initializeMap = function () {
+        const containerName = `map-container-${self.id}`;
+        self.mapInstance = new Map(false, false);
+        self.mapInstance.initMap(containerName, null, true);
+
+        const $filterForm = $(`#map-widget-filters-${self.id}`).find('form');
+        $filterForm.on('submit', function (e) {
+            e.preventDefault();
+            const formDataString = $filterForm.serialize();
+            fetchMapData(formDataString);
+        });
+
+        fetchMapData();
+    };
+
+    function fetchMapData(queryStr) {
+        let url = initialPageData.reverse('api_cases_with_gps');
+        if (queryStr) {
+            url += `?${queryStr}`;
+        }
+        $.ajax({
+            method: 'GET',
+            url: url,
+            data: {
+                // TODO: Add ability to paginate on UI
+                page: 1,
+                limit: 100,
+                case_type: self.caseType,
+                gps_prop_name: self.gpsPropName,
+            },
+            success: function (data) {
+                loadCases(data.items);
+            },
+            error: function () {
+                $(`#error-alert-${self.id}`).removeClass('d-none');
+            },
+        });
+    }
+
+    function loadCases(caseData) {
+        self.mapInstance.removeItemTypeFromSource('case');
+        self.mapInstance.caseMapItems([]);
+        let features = [];
+        let caseMapItems = [];
+        for (const caseItem of caseData) {
+            const parsedData = {
+                id: caseItem.id,
+                coordinates: caseItem.coordinates,
+                link: caseItem.name,
+                name: caseItem.name,
+                itemType: 'case',
+                isSelected: false,
+                customData: {},
+            };
+            const caseMapItem = new MapItem(parsedData, self.mapInstance);
+            caseMapItems.push(caseMapItem);
+            features.push(caseMapItem.getGeoJson());
+        }
+        self.mapInstance.caseMapItems(caseMapItems);
+        self.mapInstance.addDataToSource(features);
+        self.mapInstance.fitMapBounds(caseMapItems);
+    }
+};
\ No newline at end of file
diff --git a/corehq/apps/campaign/templates/campaign/dashboard.html b/corehq/apps/campaign/templates/campaign/dashboard.html
index 4986396208a6..6f81d35df108 100644
--- a/corehq/apps/campaign/templates/campaign/dashboard.html
+++ b/corehq/apps/campaign/templates/campaign/dashboard.html
@@ -2,13 +2,17 @@
 {% load compress %}
 {% load hq_shared_tags %}
 {% load i18n %}
-{% js_entry "hqwebapp/js/htmx_and_alpine" %}
+{% js_entry "campaign/js/dashboard" %}
 
 {% block title %}
   {% trans "Campaign Dashboard" %}
 {% endblock %}
 
 {% block page_content %}
+  {% initial_page_data 'mapbox_access_token' mapbox_access_token %}
+  {% registerurl 'api_cases_with_gps' request.domain %}
+  {% initial_page_data 'map_widgets' map_widgets %}
+
   <ul class="nav nav-tabs" role="tablist">
     <li role="presentation" class="nav-item">
       <a
@@ -43,6 +47,11 @@ <h2>{% trans "Gauges" %}</h2>
       <!-- Report and Map Section -->
       <div class="row">
         <h2>{% trans "Reports" %}</h2>
+        <div id="map-reports-container-cases">
+          {% for widget in map_widgets.cases %}
+            {% include 'campaign/partials/dashboard_map.html' %}
+          {% endfor %}
+        </div>
       </div>
     </div>
 
@@ -56,6 +65,11 @@ <h2>{% trans "Gauges" %}</h2>
       <!-- Report and Map Section -->
       <div class="row">
         <h2>{% trans "Reports" %}</h2>
+        <div id="map-reports-container-mobile-workers">
+          {% for widget in map_widgets.mobile_workers %}
+            {% include 'campaign/partials/dashboard_map.html' %}
+          {% endfor %}
+        </div>
       </div>
     </div>
   </div>
diff --git a/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html b/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
new file mode 100644
index 000000000000..092127364b67
--- /dev/null
+++ b/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
@@ -0,0 +1,51 @@
+{% load hq_shared_tags %}
+{% load i18n %}
+
+<div id="map-widget-{{ widget.id }}" class="paginated-map-widget mb-4">
+  <div class="card">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">{{ widget.title }}</h5>
+    </div>
+
+    <!-- TODO: This needs pagination support -->
+    <div class="card-body p-0">
+      <div id="error-alert-{{ widget.id }}" class="alert alert-danger d-none">
+        {% blocktrans %}
+          Something went wrong while loading this widget! Please try again.
+        {% endblocktrans %}
+      </div>
+      <div class="row g-0">
+        <!-- Left Column: Filter Space -->
+        <div class="col-md-4 border-end">
+          <div id="map-widget-filters-{{ widget.id }}" class="p-3">
+            {% include "reports/standard/partials/bootstrap5/filter_panel.html" %}
+          </div>
+        </div>
+
+        <!-- Right Column: Map -->
+        <div class="col-md-8">
+          <div class="position-relative">
+            <div
+              id="map-container-{{ widget.id }}"
+              data-case-type="{{ widget.case_type }}"
+              data-gps-prop="{{ widget.gps_prop_name }}"
+              class="map-container"
+              style="height:500px"
+            ></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- Popup for map markers -->
+<script type="text/html" id="select-case">
+  <small data-bind="html: getItemType()"></small>
+  <div class="form-check">
+    <label
+      class="form-check-label"
+      data-bind="html: $data.link, attr: {for: selectCssId}"
+    ></label>
+  </div>
+</script>
diff --git a/corehq/apps/campaign/tests/test_views.py b/corehq/apps/campaign/tests/test_views.py
new file mode 100644
index 000000000000..46254b420f49
--- /dev/null
+++ b/corehq/apps/campaign/tests/test_views.py
@@ -0,0 +1,160 @@
+from uuid import uuid4
+
+from django.test import TestCase
+from django.urls import reverse
+
+from corehq.apps.campaign.models import (
+    Dashboard,
+    DashboardMap,
+    DashboardTab,
+)
+from corehq.apps.campaign.views import (
+    DashboardView,
+    PaginatedCasesWithGPSView,
+)
+from corehq.apps.domain.shortcuts import create_domain
+from corehq.apps.es import case_search_adapter
+from corehq.apps.es.tests.utils import es_test
+from corehq.apps.users.models import WebUser
+from corehq.form_processor.tests.utils import create_case
+from corehq.util.test_utils import flag_enabled
+
+
+class BaseTestCampaignView(TestCase):
+    domain = 'test-domain'
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.domain_obj = create_domain(cls.domain)
+        cls.username = 'test-user'
+        cls.password = 'safe-password'
+        cls.webuser = WebUser.create(
+            cls.domain,
+            cls.username,
+            cls.password,
+            created_by=None,
+            created_via=None,
+            is_admin=True,
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.webuser.delete(None, None)
+        cls.domain_obj.delete()
+        super().tearDownClass()
+
+    @property
+    def endpoint(self):
+        return reverse(self.urlname, args=[self.domain])
+
+    @property
+    def login_endpoint(self):
+        return reverse('domain_login', kwargs={'domain': self.domain})
+
+    def _make_request(self, query_data={}, is_logged_in=True):
+        if is_logged_in:
+            self.client.login(username=self.username, password=self.password)
+        return self.client.get(self.endpoint, query_data)
+
+
+class TestDashboardView(BaseTestCampaignView):
+    urlname = DashboardView.urlname
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.dashboard = Dashboard.objects.create(domain=cls.domain)
+        cls.dashboard_map_cases = DashboardMap.objects.create(
+            dashboard=cls.dashboard,
+            title='Cases Map',
+            case_type='foo',
+            geo_case_property='somewhere',
+            dashboard_tab=DashboardTab.CASES,
+        )
+        cls.dashboard_map_mobile_workers = DashboardMap.objects.create(
+            dashboard=cls.dashboard,
+            title='Mobile Workers Map',
+            description='My cool map',
+            case_type='bar',
+            geo_case_property='nowhere',
+            dashboard_tab=DashboardTab.MOBILE_WORKERS,
+        )
+
+    def test_not_logged_in(self):
+        response = self._make_request(is_logged_in=False)
+        self.assertRedirects(response, f"{self.login_endpoint}?next={self.endpoint}")
+
+    def test_ff_not_enabled(self):
+        response = self._make_request(is_logged_in=True)
+        assert response.status_code == 404
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    def test_success(self):
+        response = self._make_request(is_logged_in=True)
+        assert response.status_code == 200
+
+        context = response.context
+        assert context['map_widgets'] == {
+            'cases': [{
+                'id': self.dashboard_map_cases.id,
+                'title': 'Cases Map',
+                'description': None,
+                'case_type': 'foo',
+                'geo_case_property': 'somewhere',
+            }],
+            'mobile_workers': [{
+                'id': self.dashboard_map_mobile_workers.id,
+                'title': 'Mobile Workers Map',
+                'description': 'My cool map',
+                'case_type': 'bar',
+                'geo_case_property': 'nowhere',
+            }],
+        }
+
+
+@es_test(requires=[case_search_adapter], setup_class=True)
+class TestPaginatedCasesWithGPSView(BaseTestCampaignView):
+    case_type = 'person'
+    gps_property = 'location'
+    urlname = PaginatedCasesWithGPSView.urlname
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.case_a = create_case(
+            cls.domain,
+            case_id=uuid4().hex,
+            case_type=cls.case_type,
+            name='CaseA',
+            case_json={
+                cls.gps_property: '12.34 45.67'
+            },
+            save=True,
+        )
+        cls.case_b = create_case(
+            cls.domain,
+            case_id=uuid4().hex,
+            case_type=cls.case_type,
+            name='CaseB',
+            case_json={
+                cls.gps_property: '23.45 56.78'
+            },
+            save=True,
+        )
+        case_search_adapter.bulk_index([cls.case_a, cls.case_b], refresh=True)
+
+    def test_not_logged_in(self):
+        response = self._make_request(is_logged_in=False)
+        self.assertRedirects(response, f"{self.login_endpoint}?next={self.endpoint}")
+
+    def test_get_paginated_cases_with_gps(self):
+        query_data = {
+            'case_type': self.case_type,
+            'gps_prop_name': self.gps_property,
+        }
+        response = self._make_request(query_data)
+        assert response.status_code == 200
+
+        json_data = response.json()
+        assert json_data['total'] == 2
diff --git a/corehq/apps/campaign/urls.py b/corehq/apps/campaign/urls.py
index e34042aad9fa..eda54e7d2e22 100644
--- a/corehq/apps/campaign/urls.py
+++ b/corehq/apps/campaign/urls.py
@@ -1,7 +1,12 @@
 from django.urls import re_path as url
 
-from corehq.apps.campaign.views import DashboardView
+from corehq.apps.campaign.views import (
+    DashboardView,
+    PaginatedCasesWithGPSView,
+)
 
 urlpatterns = [
     url(r'dashboard/', DashboardView.as_view(), name=DashboardView.urlname),
+    url(r'^api/cases_with_gps/$', PaginatedCasesWithGPSView.as_view(),
+        name=PaginatedCasesWithGPSView.urlname),
 ]
diff --git a/corehq/apps/campaign/views.py b/corehq/apps/campaign/views.py
index 21b53aec23ba..ef010e7a8eb7 100644
--- a/corehq/apps/campaign/views.py
+++ b/corehq/apps/campaign/views.py
@@ -1,18 +1,61 @@
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.forms.models import model_to_dict
+from django.http import JsonResponse
 from django.urls import reverse
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy
+from django.views.decorators.http import require_GET
 
-from corehq import toggles
+from memoized import memoized
+
+from dimagi.utils.web import json_request
 
+from corehq import toggles
+from corehq.apps.campaign.models import Dashboard
 from corehq.apps.domain.decorators import login_and_domain_required
+from corehq.apps.domain.views.base import BaseDomainView
+from corehq.apps.es.case_search import CaseSearchES, case_property_missing
+from corehq.apps.geospatial.utils import get_lat_lon_from_dict
+from corehq.apps.hqwebapp.crispy import CSS_ACTION_CLASS
 from corehq.apps.hqwebapp.decorators import use_bootstrap5
+from corehq.apps.reports.generic import get_filter_classes
+from corehq.apps.reports.standard.cases.basic import CaseListMixin
 from corehq.apps.reports.views import BaseProjectReportSectionView
+from corehq.form_processor.models import CommCareCase
+from corehq.util.timezones.utils import get_timezone
+
+
+class DashboardMapFilterMixin(object):
+    fields = [
+        'corehq.apps.reports.filters.case_list.CaseListFilter',
+        'corehq.apps.reports.standard.cases.filters.CaseSearchFilter',
+    ]
+
+    def dashboard_map_case_filters_context(self):
+        return {
+            'report': {
+                'title': self.page_title,
+                'section_name': self.section_name,
+                'show_filters': True,
+            },
+            'report_filters': [
+                dict(field=f.render(), slug=f.slug) for f in self.dashboard_map_filter_classes
+            ],
+            'report_filter_form_action_css_class': CSS_ACTION_CLASS,
+        }
+
+    @property
+    @memoized
+    def dashboard_map_filter_classes(self):
+        timezone = get_timezone(self.request, self.domain)
+        return get_filter_classes(self.fields, self.request, self.domain, timezone)
 
 
 @method_decorator(login_and_domain_required, name='dispatch')
 @method_decorator(toggles.CAMPAIGN_DASHBOARD.required_decorator(), name='dispatch')
 @method_decorator(use_bootstrap5, name='dispatch')
-class DashboardView(BaseProjectReportSectionView):
+class DashboardView(BaseProjectReportSectionView, DashboardMapFilterMixin):
     urlname = 'campaign_dashboard'
     page_title = gettext_lazy("Campaign Dashboard")
     template_name = 'campaign/dashboard.html'
@@ -20,3 +63,96 @@ class DashboardView(BaseProjectReportSectionView):
     @property
     def page_url(self):
         return reverse(self.urlname, args=[self.domain])
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context.update({
+            'mapbox_access_token': settings.MAPBOX_ACCESS_TOKEN,
+            'map_widgets': self._dashboard_map_configs,
+        })
+        context.update(self.dashboard_map_case_filters_context())
+        return context
+
+    @property
+    def _dashboard_map_configs(self):
+        dashboard_maps = Dashboard.objects.get(domain=self.domain).maps.all()
+        dashboard_map_configs = {
+            'cases': [],
+            'mobile_workers': [],
+        }
+        for dashboard_map in dashboard_maps:
+            config = model_to_dict(dashboard_map, exclude=['dashboard', 'dashboard_tab', 'display_order'])
+            dashboard_map_configs[dashboard_map.dashboard_tab].append(config)
+        return dashboard_map_configs
+
+
+@method_decorator([login_and_domain_required, require_GET], name='dispatch')
+class PaginatedCasesWithGPSView(BaseDomainView, CaseListMixin):
+    urlname = 'api_cases_with_gps'
+    search_class = CaseSearchES
+
+    def dispatch(self, request, *args, **kwargs):
+        self._request_params = json_request(request.GET)
+        return super().dispatch(request, *args, **kwargs)
+
+    def get(self, request, domain, *args, **kwargs):
+        return JsonResponse(self._get_paginated_cases_with_gps())
+
+    def _get_paginated_cases_with_gps(self):
+        case_type = self.request.GET.get('case_type')
+        gps_property = self.request.GET.get('gps_prop_name')
+
+        query = self._build_query()
+        query = query.case_type(case_type)
+        query = query.NOT(case_property_missing(gps_property))
+        query = query.sort('server_modified_on', desc=True)
+
+        cases, total = self._get_cases_page(query)
+        case_data = []
+        for case_obj in cases:
+            case_row = self._parse_case(case_obj, gps_property)
+            if case_row:
+                case_data.append(case_row)
+
+        return {
+            'items': case_data,
+            'total': total,
+        }
+
+    def _base_query(self):
+        return (
+            self.search_class()
+            .domain(self.domain)
+        )
+
+    def _parse_case(self, case_obj, gps_property):
+        lat, lon = get_lat_lon_from_dict(case_obj.case_json, gps_property)
+
+        # This is a precautionary measure for when there is a case with no GPS data
+        # but the ES index hasn't been updated to reflect this yet
+        if not lat and lon:
+            return None
+
+        return {
+            'id': case_obj.case_id,
+            'name': case_obj.name,
+            'coordinates': {
+                'lat': lat,
+                'lng': lon,
+            },
+            'lat': lat,
+            'lng': lon,
+        }
+
+    def _get_cases_page(self, query):
+        case_ids = query.get_ids()
+        page = int(self.request.GET.get('page', 1))
+        limit = int(self.request.GET.get('limit', 100))
+
+        paginator = Paginator(case_ids, limit)
+        case_ids_page = list(paginator.get_page(page))
+
+        return (
+            CommCareCase.objects.get_cases(case_ids_page, self.domain, ordered=True),
+            paginator.count,
+        )
diff --git a/corehq/apps/geospatial/static/geospatial/js/models.js b/corehq/apps/geospatial/static/geospatial/js/models.js
index 51ea1c616730..d1f943957dc4 100644
--- a/corehq/apps/geospatial/static/geospatial/js/models.js
+++ b/corehq/apps/geospatial/static/geospatial/js/models.js
@@ -10,6 +10,8 @@ hqDefine('geospatial/js/models', [
     '@mapbox/mapbox-gl-draw',
     '@turf/turf',
     'hqwebapp/js/components/pagination',
+    'style-loader!mapbox-gl/dist/mapbox-gl.css',
+    'style-loader!@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css',
 ], function (
     $,
     ko,
@@ -162,7 +164,7 @@ hqDefine('geospatial/js/models', [
 
         self.DISBURSEMENT_LINES_LAYER_ID = 'disbursement-lines';
 
-        self.initMap = function (mapDivId, centerCoordinates) {
+        self.initMap = function (mapDivId, centerCoordinates, disableDrawControls = false) {
             mapboxgl.accessToken = initialPageData.get('mapbox_access_token');
             if (!centerCoordinates) {
                 centerCoordinates = [-91.874, 42.76]; // should be domain specific
@@ -177,16 +179,18 @@ hqDefine('geospatial/js/models', [
                              ' <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
             });
 
-            self.drawControls = new MapboxDraw({
-                // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md
-                displayControlsDefault: false,
-                boxSelect: true, // enables box selection
-                controls: {
-                    polygon: true,
-                    trash: true,
-                },
-            });
-            self.mapInstance.addControl(self.drawControls);
+            if (!disableDrawControls) {
+                self.drawControls = new MapboxDraw({
+                    // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md
+                    displayControlsDefault: false,
+                    boxSelect: true, // enables box selection
+                    controls: {
+                        polygon: true,
+                        trash: true,
+                    },
+                });
+                self.mapInstance.addControl(self.drawControls);
+            }
 
             // Add zoom and rotation controls to the map.
             self.mapInstance.addControl(new mapboxgl.NavigationControl());
diff --git a/corehq/apps/geospatial/templates/geospatial/base_template.html b/corehq/apps/geospatial/templates/geospatial/base_template.html
index 9a2700b21783..ecc0602e984c 100644
--- a/corehq/apps/geospatial/templates/geospatial/base_template.html
+++ b/corehq/apps/geospatial/templates/geospatial/base_template.html
@@ -1,11 +1,8 @@
 {% extends "reports/bootstrap3/base_template.html" %}
-
 {% load hq_shared_tags %}
 
 {% block stylesheets %}
   {{ block.super }}
-  <link rel="stylesheet" href="{% static 'mapbox-gl/dist/mapbox-gl.css' %}">
-  <link rel="stylesheet" href="{% static '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css' %}" type="text/css">
   <style>
     #layer-toggle-menu {
       background: #fff;
@@ -20,7 +17,7 @@
   </style>
 {% endblock %}
 
-
-{% block additional_initial_page_data %}{{ block.super }}
-    {% initial_page_data "mapbox_access_token" mapbox_access_token %}
+{% block additional_initial_page_data %}
+  {{ block.super }}
+  {% initial_page_data "mapbox_access_token" mapbox_access_token %}
 {% endblock %}
diff --git a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/hq_report.js.diff.txt b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/hq_report.js.diff.txt
index 37c7cc2d53a4..291f8e121aca 100644
--- a/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/hq_report.js.diff.txt
+++ b/corehq/apps/hqwebapp/tests/data/bootstrap5_diffs/javascript/reports/js/hq_report.js.diff.txt
@@ -48,7 +48,7 @@
              };
  
              $(self.filterAccordion).on('show.bs.collapse', showFilterButtonStatus);
-@@ -178,14 +178,14 @@
+@@ -178,7 +178,7 @@
          self.resetFilterState = function () {
              $('#paramSelectorForm fieldset button, #paramSelectorForm fieldset span[data-dropdown="dropdown"]').click(function () {
                  $(self.filterSubmitSelector)
@@ -57,15 +57,7 @@
                      .addClass('btn-primary')
                      .removeClass('disabled')
                      .prop('disabled', false);
-             });
-             $('#paramSelectorForm fieldset').on('change apply', function () {
-                 $(self.filterSubmitSelector)
--                    .button('reset')
-+                    .changeButtonState('reset')
-                     .addClass('btn-primary')
-                     .removeClass('disabled')
-                     .prop('disabled', false);
-@@ -254,16 +254,16 @@
+@@ -257,16 +257,16 @@
  
              self.sendEmail = function () {
                  var $sendButton = $(hqReport.emailReportModal).find('.send-button');
@@ -85,7 +77,7 @@
                          self.resetModal();
                          const errors = JSON.parse(response.responseText);
                          let messages = [hqReport.emailErrorMessage].concat(errors);
-@@ -273,7 +273,7 @@
+@@ -276,7 +276,7 @@
              };
  
              self.resetModal = function () {
diff --git a/corehq/apps/reports/static/reports/js/bootstrap3/hq_report.js b/corehq/apps/reports/static/reports/js/bootstrap3/hq_report.js
index 6c788241dbda..cff02e05a466 100644
--- a/corehq/apps/reports/static/reports/js/bootstrap3/hq_report.js
+++ b/corehq/apps/reports/static/reports/js/bootstrap3/hq_report.js
@@ -183,9 +183,12 @@ hqDefine("reports/js/bootstrap3/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
-                    .button('reset')
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
+                // Ensure correct submit button is found in context of which form the button belongs to.
+                // This is necessary for pages that contain multiple filter panels, since we are using CSS IDs.
+                const submitButton = $(e.target).closest('#reportFilters').find(self.filterSubmitSelector);
+                $(submitButton)
+                    .changeButtonState('reset')
                     .addClass('btn-primary')
                     .removeClass('disabled')
                     .prop('disabled', false);
diff --git a/corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js b/corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
index 77c24d806675..e419c8ac4878 100644
--- a/corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
+++ b/corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
@@ -183,8 +183,11 @@ hqDefine("reports/js/bootstrap5/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
+                // Ensure correct submit button is found in context of which form the button belongs to.
+                // This is necessary for pages that contain multiple filter panels, since we are using CSS IDs.
+                const submitButton = $(e.target).closest('#reportFilters').find(self.filterSubmitSelector);
+                $(submitButton)
                     .changeButtonState('reset')
                     .addClass('btn-primary')
                     .removeClass('disabled')


Reviews:
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:50:32Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:51:21Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T13:53:40Z
user: login: orangejenny
body: I'm not really reviewing, just skimmed over this and had some minor thoughts.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T14:52:32Z
user: login: ajeety4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T07:49:40Z
user: login: ajeety4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T07:59:32Z
user: login: ajeety4
body: This looks great. Liked the reuse of the geospatial map model. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T08:00:23Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T08:46:59Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:05:05Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:05:28Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:05:43Z
user: login: zandre-eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T09:08:11Z
user: login: kaapstorm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T13:41:55Z
user: login: kaapstorm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T15:12:46Z
user: login: orangejenny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T16:48:56Z
user: login: ajeety4
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T07:19:15Z

Review comments:
diff_hunk: @@ -1,22 +1,165 @@
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.http import JsonResponse
 from django.urls import reverse
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy
+from django.views.decorators.http import require_GET
 
-from corehq import toggles
+from memoized import memoized
+
+from dimagi.utils.web import json_request
 
+from corehq import toggles
+from corehq.apps.campaign.models import Dashboard
 from corehq.apps.domain.decorators import login_and_domain_required
+from corehq.apps.domain.views.base import BaseDomainView
+from corehq.apps.es.case_search import CaseSearchES, case_property_missing
+from corehq.apps.geospatial.utils import get_lat_lon_from_dict
+from corehq.apps.hqwebapp.crispy import CSS_ACTION_CLASS
 from corehq.apps.hqwebapp.decorators import use_bootstrap5
+from corehq.apps.reports.generic import get_filter_classes
+from corehq.apps.reports.standard.cases.basic import CaseListMixin
 from corehq.apps.reports.views import BaseProjectReportSectionView
+from corehq.form_processor.models import CommCareCase
+from corehq.util.timezones.utils import get_timezone
+
+
+class DashboardMapFilterMixin(object):
path: corehq/apps/campaign/views.py
user: login: zandre-eng
body: This filter context needs to be added to the view that loads the dashboard page so that the JS code which gets loaded at the same time has the necessary context to properly render the filters.
created_at: 2025-03-12T13:50:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+{% load hq_shared_tags %}
+{% load i18n %}
+
+<div id="map-widget-{{ widget.id }}" class="paginated-map-widget mb-4">
+  <div class="card">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">{{ widget.title }}</h5>
+    </div>
+
+    <!-- TODO: This needs pagination support -->
path: corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
user: login: zandre-eng
body: It has been confirmed that this is not an immediate requirement, however it is something we'll need to add in the future. For now, maps will default to only showing the first page of 100 cases.
created_at: 2025-03-12T13:51:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,8 +183,9 @@ hqDefine("reports/js/bootstrap5/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
path: corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
user: login: zandre-eng
body: The filter panel expects there to be only one submit button on a page, and so uses CSS IDs to find the correct "Apply" button. This change simply ensures that the correct apply button is found in the context of which form the button belongs to.

Without the above change this event listener propogates to all filter panels on the page.
created_at: 2025-03-12T13:53:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,13 +2,27 @@
 {% load compress %}
 {% load hq_shared_tags %}
 {% load i18n %}
-{% js_entry "hqwebapp/js/htmx_and_alpine" %}
+{% js_entry "campaign/js/dashboard" %}
+
+{% block stylesheets %}
path: corehq/apps/campaign/templates/campaign/dashboard.html
user: login: orangejenny
body: Can you include these in `dashboard.js` (or more likely in `geospatial/js/models.js`)? Webpack can include stylesheets ([config here](https://github.com/dimagi/commcare-hq/blob/e190a482daeef8039fe4e54d8b707a453564e205/webpack/webpack.common.js#L34-L37)).
created_at: 2025-03-12T14:46:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+{% load hq_shared_tags %}
+{% load i18n %}
+
+<div id="map-widget-{{ widget.id }}" class="paginated-map-widget mb-4">
+  <div class="card">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">{{ widget.title }}</h5>
+    </div>
+
+    <!-- TODO: This needs pagination support -->
path: corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
user: login: orangejenny
body: You might know this already, but I think Biyeun created an Alpine-based pagination widget as a proof of concept. I'm not sure if it's production-ready or needs more work, but either way, it'd be a great contribution to HQ to have a reusable pagination widget in Alpine. Makes sense that it's out of scope right now, just thinking about the future.
created_at: 2025-03-12T14:51:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,9 +183,10 @@ hqDefine("reports/js/bootstrap3/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
-                    .button('reset')
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
+                const submitButton = $(e.target).closest('#reportFilters').find(self.filterSubmitSelector);
path: corehq/apps/reports/static/reports/js/bootstrap3/hq_report.js
user: login: orangejenny
body: 👍 
created_at: 2025-03-12T14:51:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,8 +183,9 @@ hqDefine("reports/js/bootstrap5/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
path: corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
user: login: orangejenny
body: Could you put this as a comment in the code itself?
created_at: 2025-03-12T14:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,7 +162,7 @@ hqDefine('geospatial/js/models', [
 
         self.DISBURSEMENT_LINES_LAYER_ID = 'disbursement-lines';
 
-        self.initMap = function (mapDivId, centerCoordinates) {
+        self.initMap = function (mapDivId, centerCoordinates, disableDrawControls) {
path: corehq/apps/geospatial/static/geospatial/js/models.js
user: login: ajeety4
body: nit: can set default value of `disableDrawControls` to `false` to be explicit.
created_at: 2025-03-13T07:49:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,3 +91,75 @@ def _dashboard_map_configs(self):
             else:
                 dashboard_map_configs['mobile_workers'].append(config)
         return dashboard_map_configs
+
+
+@method_decorator([login_and_domain_required, require_GET], name='dispatch')
+class PaginatedCasesWithGPSView(BaseDomainView, CaseListMixin):
path: corehq/apps/campaign/views.py
user: login: ajeety4
body: Do we want to add the FF required decorator here too ?
created_at: 2025-03-13T07:59:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,3 +91,75 @@ def _dashboard_map_configs(self):
             else:
                 dashboard_map_configs['mobile_workers'].append(config)
         return dashboard_map_configs
+
+
+@method_decorator([login_and_domain_required, require_GET], name='dispatch')
+class PaginatedCasesWithGPSView(BaseDomainView, CaseListMixin):
path: corehq/apps/campaign/views.py
user: login: zandre-eng
body: I was thinking about this and was in two minds about it. On one hand we're only using it for the campaign functionality now, but this API isn't specific to only the campaign functionality. It could be used in other places as well.

In saying the above, this view is in the `campaign` Django app so I'm happy to add a FF check here. If this ever gets needed in the future we can always reconsider removing the check and possibly moving this view to somewhere more applicable.
created_at: 2025-03-13T08:46:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,13 +2,27 @@
 {% load compress %}
 {% load hq_shared_tags %}
 {% load i18n %}
-{% js_entry "hqwebapp/js/htmx_and_alpine" %}
+{% js_entry "campaign/js/dashboard" %}
+
+{% block stylesheets %}
path: corehq/apps/campaign/templates/campaign/dashboard.html
user: login: zandre-eng
body: Stylesheet imports for both the `geospatial` and `campaign` features have been moved to the `geospatial/js/models.js` webpack ([e782b0f](https://github.com/dimagi/commcare-hq/pull/35953/commits/e782b0fbfcdd7142252c93f94eb8338a57139e0b)).
created_at: 2025-03-13T09:05:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -183,8 +183,9 @@ hqDefine("reports/js/bootstrap5/hq_report", [
                     .removeClass('disabled')
                     .prop('disabled', false);
             });
-            $('#paramSelectorForm fieldset').on('change apply', function () {
-                $(self.filterSubmitSelector)
+            $('#paramSelectorForm fieldset').on('change apply', function (e) {
path: corehq/apps/reports/static/reports/js/bootstrap5/hq_report.js
user: login: zandre-eng
body: Addressed in [e001673](https://github.com/dimagi/commcare-hq/pull/35953/commits/e001673e647a30d83fa3efc17e107c63d10e9aca).
created_at: 2025-03-13T09:05:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -162,7 +162,7 @@ hqDefine('geospatial/js/models', [
 
         self.DISBURSEMENT_LINES_LAYER_ID = 'disbursement-lines';
 
-        self.initMap = function (mapDivId, centerCoordinates) {
+        self.initMap = function (mapDivId, centerCoordinates, disableDrawControls) {
path: corehq/apps/geospatial/static/geospatial/js/models.js
user: login: zandre-eng
body: Addressed in [e001673](https://github.com/dimagi/commcare-hq/pull/35953/commits/e001673e647a30d83fa3efc17e107c63d10e9aca).
created_at: 2025-03-13T09:05:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+{% load hq_shared_tags %}
+{% load i18n %}
+
+<div id="map-widget-{{ widget.id }}" class="paginated-map-widget mb-4">
+  <div class="card">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">{{ widget.title }}</h5>
+    </div>
+
+    <!-- TODO: This needs pagination support -->
path: corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
user: login: zandre-eng
body: I didn't know! This is very interesting, and will definitely be taking a look. But agreed having an Alpine pagination widget would be super helpful, especially given that KO is being deprecated and removed.
created_at: 2025-03-13T09:08:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,13 +2,27 @@
 {% load compress %}
 {% load hq_shared_tags %}
 {% load i18n %}
-{% js_entry "hqwebapp/js/htmx_and_alpine" %}
+{% js_entry "campaign/js/dashboard" %}
+
+{% block stylesheets %}
path: corehq/apps/campaign/templates/campaign/dashboard.html
user: login: orangejenny
body: sweet, thanks
created_at: 2025-03-13T16:48:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: zandre-eng
created_at: 2025-03-13T14:00:22Z
body: No new changes, but tagging for re-review as it's saying the reviews are stale and need another approval

FYI @ajeety4 @kaapstorm 
author_association: CONTRIBUTOR
user: login: gherceg
created_at: 2025-03-17T21:23:04Z
body: Hey @zandre-eng I'm seeing an issue locally and now on staging that I confirmed is not an issue in the current release. I'll offline with you, but believe it is related to the change here. The reproduction steps are:
1. Navigate to a report, like [this one](https://staging.commcarehq.org/a/gherceg/reports/case_activity/)
2. Click Apply
3. Update filters
4. See that the Apply button does not get re-enabled and that the following JS error appears in the console
```
hq_report.js:191 Uncaught TypeError: e(...).changeButtonState is not a function
    at HTMLFieldSetElement.<anonymous> (hq_report.js:191:22)
```
author_association: CONTRIBUTOR
user: login: zandre-eng
created_at: 2025-03-18T08:37:49Z
body: > Hey @zandre-eng I'm seeing an issue locally and now on staging that I confirmed is not an issue in the current release. I'll offline with you, but believe it is related to the change here. The reproduction steps are

Hey @gherceg, thanks for flagging this. I have pushed out a PR [here](https://github.com/dimagi/commcare-hq/pull/35992) that resolves the above issue. This looks to be a bug introduced specifically for filter panels using Boostrap 3.
author_association: CONTRIBUTOR