Repository: dimagi/commcare-hq
Pull request #36041: Ability to edit widget in Campagin Dashboard
Author: login: ajeety4
Labels: name: product/feature-flag
description: Change will only affect users who have a specific feature flag enabled
Created: 2025-03-21T12:33:55Z | Merged: 2025-03-28T08:46:19Z

## Product Description
<!-- Where applicable, describe user-facing effects and include screenshots. -->
This PR adds the ability to edit widget in the Campagin Dashboard. This is currently supported for widget of types `map` and `report` and may be extended to `Gauge` in future.

**Screenshots**

**Edit Button:**
![image](https://github.com/user-attachments/assets/4e29d1e9-3612-4da3-862e-1f90def62ee1)

**Edit Widget Form:**
On clicking the edit icon,

![image](https://github.com/user-attachments/assets/4a2ec947-8163-41f6-93c7-935a14819b45)

After save, 
![image](https://github.com/user-attachments/assets/96ff3db6-5f09-40c7-9adc-445c8e23685f)

## Technical Summary
<!--
    Provide a link to any tickets, design documents, and/or technical specifications
    associated with this change. Describe the rationale and design decisions.
-->
[Ticket](https://dimagi.atlassian.net/browse/SC-4388)

## 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.
-->
Testing done on local. Changes sit behind an unused feature flag.

### Automated test coverage

<!-- Identify the related test coverage and the tests it would catch -->
New tests included

### QA Plan

<!--
- Describe QA plan that along with automated test coverages proves this PR is regression free
- Link to QA Ticket
-->
None


### 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/forms.py b/corehq/apps/campaign/forms.py
index 47e10950d52a..53ea747b5c27 100644
--- a/corehq/apps/campaign/forms.py
+++ b/corehq/apps/campaign/forms.py
@@ -111,7 +111,6 @@ def __init__(self, domain, *args, **kwargs):
                     hx_trigger='change',
                     hx_target='#geo-case-property select',
                     hx_swap='innerHTML',
-                    hx_indicator=".htmx-indicator",
                 ),
                 crispy.Div(
                     'geo_case_property',
diff --git a/corehq/apps/campaign/static/campaign/js/dashboard.js b/corehq/apps/campaign/static/campaign/js/dashboard.js
index 1f98469cb5a2..9c0307407cab 100644
--- a/corehq/apps/campaign/static/campaign/js/dashboard.js
+++ b/corehq/apps/campaign/static/campaign/js/dashboard.js
@@ -9,6 +9,12 @@ import html2pdf from "html2pdf.js";
 
 let mobileWorkerMapsInitialized = false;
 
+const widgetModalSelector = '#widget-modal';
+const modalTitleSelector = '.modal-title';
+const addWidgetText = gettext('Add Widget');
+const editWidgetText = gettext('Edit Widget');
+let $modalTitleElement = null;
+
 $(function () {
     // Only init case map widgets since this is the default tab
     const widgetConfigs = initialPageData.get('map_report_widgets');
@@ -21,12 +27,11 @@ $(function () {
     $('a[data-bs-toggle="tab"]').on('shown.bs.tab', tabSwitch);
     $('#print-to-pdf').on('click', printActiveTabToPdf);
 
-    $('#widget-modal').on('hidden.bs.modal', function () {
-        $('#widget-modal-spinner').removeClass('d-none');
-        $('#widget-form').text('');
-    });
+    $modalTitleElement = $(widgetModalSelector).find(modalTitleSelector);
+    $(widgetModalSelector).on('hidden.bs.modal', onHideWidgetModal);
+    $(widgetModalSelector).on('show.bs.modal', onShowWidgetModal);
 
-    $('#widget-modal').on('htmx:afterSwap', htmxAfterSwapWidgetForm);
+    $(widgetModalSelector).on('htmx:afterSwap', htmxAfterSwapWidgetForm);
 });
 
 function tabSwitch(e) {
@@ -168,3 +173,17 @@ var htmxAfterSwapWidgetForm = function (event) {
         }, 1000);
     }
 };
+
+var onHideWidgetModal = function () {
+    $('#widget-modal-spinner').removeClass('d-none');
+    $('#widget-form').text('');
+};
+
+var onShowWidgetModal = function (event) {
+    const triggerSource = event.relatedTarget;
+    if (triggerSource.id === 'edit-widget-btn') {
+        $modalTitleElement.text(editWidgetText);
+    } else {
+        $modalTitleElement.text(addWidgetText);
+    }
+};
diff --git a/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html b/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
index 092127364b67..570e9c42adf8 100644
--- a/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
+++ b/corehq/apps/campaign/templates/campaign/partials/dashboard_map.html
@@ -5,6 +5,7 @@
   <div class="card">
     <div class="card-header d-flex justify-content-between align-items-center">
       <h5 class="mb-0">{{ widget.title }}</h5>
+      {% include "campaign/partials/edit_widget_button.html" with widget_type='map' %}
     </div>
 
     <!-- TODO: This needs pagination support -->
diff --git a/corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html b/corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html
new file mode 100644
index 000000000000..b8d9754f0b1c
--- /dev/null
+++ b/corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+
+<button
+  id="edit-widget-btn"
+  class="btn btn-default"
+  type="button"
+  data-bs-toggle="modal"
+  data-bs-target="#widget-modal"
+  hx-target="#widget-form"
+  hx-get="{% url 'dashboard_widget' domain %}?widget_type={{ widget_type }}&widget_id={{ widget.id }}"
+  hq-hx-action="edit_widget"
+  aria-label="{% trans 'Edit Widget' %}"
+>
+  <i class="fa fa-edit"></i>
+</button>
diff --git a/corehq/apps/campaign/templates/campaign/partials/widget_form.html b/corehq/apps/campaign/templates/campaign/partials/widget_form.html
index 59e6d9811c44..3d4e51cbeb26 100644
--- a/corehq/apps/campaign/templates/campaign/partials/widget_form.html
+++ b/corehq/apps/campaign/templates/campaign/partials/widget_form.html
@@ -19,8 +19,12 @@
   hq-hx-action="save_widget"
   hx-post="{{ request.path_info }}"
   hx-target="#widget-form"
-  hx-swap="outerHTML"
-  hx-vals='{"widget_type": "{{ widget_type }}"}'
+  hx-swap="innerHTML"
+  hx-vals='{
+    "widget_type": "{{ widget_type }}",
+    "widget_id": "{{ widget.id }}"
+  }'
+  hx-disabled-elt="find input[type='submit']"
 >
   {% crispy widget_form %}
 </form>
diff --git a/corehq/apps/campaign/templates/campaign/partials/widget_modal.html b/corehq/apps/campaign/templates/campaign/partials/widget_modal.html
index 6971e4efa088..fd2ebf246254 100644
--- a/corehq/apps/campaign/templates/campaign/partials/widget_modal.html
+++ b/corehq/apps/campaign/templates/campaign/partials/widget_modal.html
@@ -4,7 +4,7 @@
   <div class="modal-dialog">
     <div class="modal-content">
       <div class="modal-header">
-        <h5 class="modal-title">{% trans "Add Widget" %}</h5>
+        <h5 class="modal-title"></h5>
         <button
           type="button"
           class="btn-close"
diff --git a/corehq/apps/campaign/tests/test_views.py b/corehq/apps/campaign/tests/test_views.py
index 4eb719f0d830..e194dcc68cff 100644
--- a/corehq/apps/campaign/tests/test_views.py
+++ b/corehq/apps/campaign/tests/test_views.py
@@ -188,6 +188,15 @@ def test_get_paginated_cases_with_gps(self):
 
 class TestDashboardWidgetView(BaseTestCampaignView):
     urlname = DashboardWidgetView.urlname
+    HQ_ACTION_NEW_WIDGET = 'new_widget'
+    HQ_ACTION_EDIT_WIDGET = 'edit_widget'
+    HQ_ACTION_SAVE_WIDGET = 'save_widget'
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.dashboard = Dashboard.objects.create(domain=cls.domain)
+        cls.addClassCleanup(cls.dashboard.delete)
 
     def tearDown(self):
         DashboardMap.objects.all().delete()
@@ -202,12 +211,21 @@ def test_ff_not_enabled(self):
         response = self._make_request(is_logged_in=True)
         assert response.status_code == 404
 
+    @staticmethod
+    def _assert_for_success(response, widget_type):
+        assert response.status_code == 200
+        assert response.context['widget_type'] == widget_type
+        assert isinstance(response.context['widget_form'], WidgetType.get_form_class(widget_type))
+
+
+class TestNewWidget(TestDashboardWidgetView):
+
     @flag_enabled('CAMPAIGN_DASHBOARD')
     @patch('corehq.apps.campaign.forms.DashboardMapForm._get_case_types', return_value=[])
     def test_new_map_widget(self, *args):
         response = self._make_request(
             query_data={'widget_type': WidgetType.MAP},
-            headers={'hq-hx-action': 'new_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_NEW_WIDGET},
             is_logged_in=True,
         )
 
@@ -218,7 +236,7 @@ def test_new_map_widget(self, *args):
     def test_new_report_widget(self, *args):
         response = self._make_request(
             query_data={'widget_type': WidgetType.REPORT},
-            headers={'hq-hx-action': 'new_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_NEW_WIDGET},
             is_logged_in=True,
         )
 
@@ -228,14 +246,14 @@ def test_new_report_widget(self, *args):
     def test_new_widget_invalid_widget_type(self, *args):
         response = self._make_request(
             query_data={'widget_type': 'invalid'},
-            headers={'hq-hx-action': 'new_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_NEW_WIDGET},
             is_logged_in=True,
         )
         assert response.context['htmx_error'].message == "Requested widget type is not supported"
 
     @flag_enabled('CAMPAIGN_DASHBOARD')
     @patch('corehq.apps.campaign.forms.DashboardMapForm._get_case_types')
-    def test_save_map_widget(self, mocked_case_types):
+    def test_save_new_map_widget(self, mocked_case_types):
         mocked_case_types.return_value = [('case-02', 'case-02')]
 
         self.client.login(username=self.username, password=self.password)
@@ -247,10 +265,9 @@ def test_save_map_widget(self, mocked_case_types):
                 "dashboard_tab": DashboardTab.CASES,
                 "case_type": "case-02",
                 "geo_case_property": "Test",
-                "submit": "Submit",
                 "widget_type": WidgetType.MAP
             },
-            headers={'hq-hx-action': 'save_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_SAVE_WIDGET},
         )
 
         self._assert_for_success(response, WidgetType.MAP)
@@ -258,7 +275,7 @@ def test_save_map_widget(self, mocked_case_types):
 
     @flag_enabled('CAMPAIGN_DASHBOARD')
     @patch('corehq.apps.campaign.forms.DashboardReportForm._get_report_configurations')
-    def test_save_report_widget(self, mocked_get_report_configurations):
+    def test_save_new_report_widget(self, mocked_get_report_configurations):
         report_id = uuid.uuid4().hex
         mocked_get_report_configurations.return_value = [(report_id, 'Test Report')]
 
@@ -272,14 +289,14 @@ def test_save_report_widget(self, mocked_get_report_configurations):
                 "report_configuration_id": report_id,
                 "widget_type": WidgetType.REPORT
             },
-            headers={'hq-hx-action': 'save_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_SAVE_WIDGET},
         )
 
         self._assert_for_success(response, WidgetType.REPORT)
         assert DashboardReport.objects.count() == 1
 
     @flag_enabled('CAMPAIGN_DASHBOARD')
-    def test_save_widget_form_error(self):
+    def test_save_new_widget_form_error(self):
         self.client.login(username=self.username, password=self.password)
         response = self.client.post(
             self.endpoint,
@@ -289,18 +306,129 @@ def test_save_widget_form_error(self):
                 "dashboard_tab": DashboardTab.CASES,
                 "widget_type": WidgetType.REPORT
             },
-            headers={'hq-hx-action': 'save_widget'},
+            headers={'hq-hx-action': self.HQ_ACTION_SAVE_WIDGET},
         )
 
         self._assert_for_success(response, WidgetType.REPORT)
         assert DashboardReport.objects.count() == 0
         assert response.context["widget_form"].errors == {'report_configuration_id': ['This field is required.']}
 
-    @staticmethod
-    def _assert_for_success(response, widget_type):
-        assert response.status_code == 200
-        assert response.context['widget_type'] == widget_type
-        assert isinstance(response.context['widget_form'], WidgetType.get_form_class(widget_type))
+
+class TestEditWidget(TestDashboardWidgetView):
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    @patch('corehq.apps.campaign.forms.DashboardMapForm._get_case_types', return_value=[])
+    def test_edit_map_widget_form(self, *args):
+        map_widget = self._sample_map_widget()
+
+        response = self._make_request(
+            query_data={
+                'widget_type': WidgetType.MAP,
+                'widget_id': map_widget.id,
+            },
+            headers={'hq-hx-action': self.HQ_ACTION_EDIT_WIDGET},
+            is_logged_in=True,
+        )
+
+        self._assert_for_success(response, WidgetType.MAP)
+        assert response.context['widget'] == map_widget
+
+    def _sample_map_widget(self):
+        return DashboardMap.objects.create(
+            dashboard=self.dashboard,
+            title='Cases Map',
+            case_type='foo',
+            geo_case_property='somewhere',
+            dashboard_tab=DashboardTab.CASES,
+        )
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    @patch('corehq.apps.campaign.forms.DashboardReportForm._get_report_configurations', return_value=[])
+    def test_edit_report_widget_form(self, *args):
+        report_widget = self._sample_report_widget(report_id=uuid.uuid4().hex)
+
+        response = self._make_request(
+            query_data={
+                'widget_type': WidgetType.REPORT,
+                'widget_id': report_widget.id,
+            },
+            headers={'hq-hx-action': self.HQ_ACTION_EDIT_WIDGET},
+            is_logged_in=True,
+        )
+
+        self._assert_for_success(response, WidgetType.REPORT)
+        assert response.context['widget'] == report_widget
+
+    def _sample_report_widget(self, report_id):
+        return DashboardReport.objects.create(
+            dashboard=self.dashboard,
+            title='Cases Map',
+            report_configuration_id=report_id,
+            dashboard_tab=DashboardTab.CASES,
+        )
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    @patch('corehq.apps.campaign.forms.DashboardMapForm._get_case_types')
+    def test_save_existing_map_widget(self, mocked_case_types):
+        mocked_case_types.return_value = [('foo', 'foo')]
+        map_widget = self._sample_map_widget()
+
+        self.client.login(username=self.username, password=self.password)
+        response = self.client.post(
+            self.endpoint,
+            data={
+                'title': 'New Title',
+                'dashboard_tab': map_widget.dashboard_tab,
+                'case_type': map_widget.case_type,
+                'geo_case_property': map_widget.geo_case_property,
+                'widget_type': WidgetType.MAP,
+                'widget_id': map_widget.id,
+            },
+            headers={'hq-hx-action': self.HQ_ACTION_SAVE_WIDGET},
+            is_logged_in=True,
+        )
+
+        self._assert_for_success(response, WidgetType.MAP)
+        saved_map_widget = DashboardMap.objects.get(pk=map_widget.id)
+        assert saved_map_widget.title == 'New Title'
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    @patch('corehq.apps.campaign.forms.DashboardReportForm._get_report_configurations')
+    def test_save_existing_report_widget(self, mocked_get_report_configurations):
+        report_id = uuid.uuid4().hex
+        mocked_get_report_configurations.return_value = [(report_id, 'Test Report')]
+        report_widget = self._sample_report_widget(report_id=report_id)
+
+        self.client.login(username=self.username, password=self.password)
+        response = self.client.post(
+            self.endpoint,
+            data={
+                'title': 'New Title',
+                'report_configuration_id': report_widget.report_configuration_id,
+                'dashboard_tab': DashboardTab.MOBILE_WORKERS,
+                'widget_type': WidgetType.REPORT,
+                'widget_id': report_widget.id,
+            },
+            headers={'hq-hx-action': self.HQ_ACTION_SAVE_WIDGET},
+        )
+
+        self._assert_for_success(response, WidgetType.REPORT)
+        saved_report_widget = DashboardReport.objects.get(pk=report_widget.id)
+        assert saved_report_widget.title == 'New Title'
+        assert saved_report_widget.dashboard_tab == DashboardTab.MOBILE_WORKERS
+
+    @flag_enabled('CAMPAIGN_DASHBOARD')
+    def test_edit_nonexistent_widget(self):
+        response = self._make_request(
+            query_data={
+                'widget_type': WidgetType.MAP,
+                'widget_id': 99999,
+            },
+            headers={'hq-hx-action': self.HQ_ACTION_EDIT_WIDGET},
+            is_logged_in=True,
+        )
+
+        assert response.status_code == 404
 
 
 class TestGetGeoCaseProperties(BaseTestCampaignView):
diff --git a/corehq/apps/campaign/views.py b/corehq/apps/campaign/views.py
index af5cefbbf3f7..71aad793dcf6 100644
--- a/corehq/apps/campaign/views.py
+++ b/corehq/apps/campaign/views.py
@@ -3,7 +3,7 @@
 from django.conf import settings
 from django.core.paginator import Paginator
 from django.http import HttpResponseBadRequest, JsonResponse
-from django.shortcuts import render
+from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy
@@ -210,18 +210,25 @@ def dashboard(self):
     def save_widget(self, request, *args, **kwargs):
         self._validate_request_widget_type()
 
-        widget = self.model_class(dashboard=self.dashboard)
+        if self.widget_id:
+            widget = get_object_or_404(self.model_class, pk=self.widget_id)
+        else:
+            widget = self.model_class(dashboard=self.dashboard)
+
         form = self.form_class(self.domain, request.POST, instance=widget)
         show_success = False
         if form.is_valid():
             form.save(commit=True)
             show_success = True
-            form = self.form_class(self.domain)
+            # Returns empty form if new widget created successfully
+            if not self.widget_id:
+                form = self.form_class(self.domain)
 
         context = {
             'widget_form': form,
             'widget_type': self.widget_type,
             'show_success': show_success,
+            'widget': widget,
         }
         return self.render_htmx_partial_response(request, self.form_template_partial_name, context)
 
@@ -229,6 +236,25 @@ def save_widget(self, request, *args, **kwargs):
     def model_class(self):
         return WidgetType.get_model_class(self.widget_type)
 
+    @hq_hx_action('get')
+    def edit_widget(self, request, *args, **kwargs):
+        self._validate_request_widget_type()
+
+        widget = get_object_or_404(self.model_class, pk=self.widget_id)
+        context = {
+            'widget_form': self.form_class(self.domain, instance=widget),
+            'widget_type': self.widget_type,
+            'widget': widget,
+        }
+        return self.render_htmx_partial_response(request, self.form_template_partial_name, context)
+
+    @cached_property
+    def widget_id(self):
+        if self.request.method == "GET":
+            return self.request.GET.get('widget_id')
+        else:
+            return self.request.POST.get('widget_id')
+
 
 @require_GET
 @login_and_domain_required


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html (1)</summary><blockquote>

`1-12`: **Consider adding an accessible label to the edit button**

The button has an icon but no text, which may affect accessibility. Consider adding an aria-label to improve accessibility.


```diff
 <button
   id="edit-widget-btn"
   class="btn btn-default"
   type="button"
   data-bs-toggle="modal"
   data-bs-target="#widget-modal"
   hx-target="#widget-modal-body"
   hx-get="{% url 'dashboard_widget' domain %}?widget_type=map&widget_id={{ widget.id }}"
   hq-hx-action="edit_widget"
+  aria-label="{% trans 'Edit Widget' %}"
 >
   <i class="fa fa-edit"></i>
 </button>
```

</blockquote></details>
<details>
<summary>corehq/apps/campaign/tests/test_views.py (1)</summary><blockquote>

`299-404`: **Consider adding negative test cases.**

While the positive test cases are comprehensive, adding negative test cases would strengthen the test suite.

Consider adding tests for:
1. Attempting to edit a non-existent widget
2. Submitting invalid form data during an edit
3. Testing permission checks for unauthorized users

```python
@flag_enabled('CAMPAIGN_DASHBOARD')
def test_edit_nonexistent_widget(self):
    self.client.login(username=self.username, password=self.password)
    response = self._make_request(
        query_data={
            'widget_type': WidgetType.MAP,
            'widget_id': 99999,  # Non-existent ID
        },
        headers={'hq-hx-action': self.HQ_ACTION_EDIT_WIDGET},
        is_logged_in=True,
    )
    
    # Should return a 404 response
    assert response.status_code == 404
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 2f10b824c1bfecbbb35cc7186fcd1ecb1d6efcd1 and 21bf1c33dd18696a8962ce1a290ac6ff86b19745.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `corehq/apps/campaign/static/campaign/js/dashboard.js` (3 hunks)
* `corehq/apps/campaign/templates/campaign/partials/dashboard_map.html` (1 hunks)
* `corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html` (1 hunks)
* `corehq/apps/campaign/templates/campaign/partials/widget_form.html` (1 hunks)
* `corehq/apps/campaign/templates/campaign/partials/widget_modal.html` (1 hunks)
* `corehq/apps/campaign/tests/test_views.py` (7 hunks)
* `corehq/apps/campaign/views.py` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>📓 Path-based instructions (2)</summary>

<details>
<summary>`**/*.py`: - Review the code following best practises and standards</summary>

> `**/*.py`: - Review the code following best practises and standards

- `corehq/apps/campaign/views.py`
- `corehq/apps/campaign/tests/test_views.py`

</details>
<details>
<summary>`**/*.js`: - Review the javascript code against the google javascript style guide and point out any mismatches. - User-facing language should be clear. - All user-facing text must ...</summary>

> `**/*.js`: - Review the javascript code against the google javascript style guide and point out any mismatches. - User-facing language should be clear. - All user-facing text must be translated using `gettext`; use `_.template` for variable-containing strings. - User-facing dates and times must be in a relevant time zone, using `UserTime` or `corehq.util.timezones`. - Use `<%- ... %>` in Underscore templates to HTML escape values; use `DomPurify` for escaping user input outside templates. - AJAX requests must show a spinner and have an `error` callback. - Naming should be consistent, avoiding different capitalizations of the same identifier. - JavaScript should be modular, with explicit dependencies (except in app manager, reports, and web apps). - Avoid long parameter lists; prefer kwargs-style objects and use `assert_properties` for validation. - Ensure JavaScript accessing initial page data waits until the page is fully loaded to prevent race conditions. - Prefer Knockout over jQuery and avoid mixing the two.

- `corehq/apps/campaign/static/campaign/js/dashboard.js`

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (4)</summary>

* GitHub Check: task-list-completed
* GitHub Check: task-list-completed
* GitHub Check: task-list-completed
* GitHub Check: task-list-completed

</details>

<details>
<summary>🔇 Additional comments (19)</summary><blockquote>

<details>
<summary>corehq/apps/campaign/templates/campaign/partials/widget_modal.html (1)</summary>

`7-7`: **Modal title now set dynamically**

The hardcoded "Add Widget" title has been removed, allowing the title to be set dynamically via JavaScript. This change supports the new functionality to both add and edit widgets through the same modal.

</details>
<details>
<summary>corehq/apps/campaign/templates/campaign/partials/dashboard_map.html (1)</summary>

`8-8`: **Edit button added to map widget**

The inclusion of the edit widget button template provides users with the ability to edit map widgets directly from the dashboard, enhancing the user experience.

</details>
<details>
<summary>corehq/apps/campaign/templates/campaign/partials/widget_form.html (1)</summary>

`26-31`: **Form enhanced to support both creating and editing widgets**

The `hx-vals` attribute now conditionally includes the widget ID when editing an existing widget. This is a good approach that allows the same form to be used for both creating and editing widgets.

</details>
<details>
<summary>corehq/apps/campaign/templates/campaign/partials/edit_widget_button.html (1)</summary>

`8-8`: <details>
<summary>✅ Verification successful</summary>

**Hardcoded widget type in URL**

The widget type is hardcoded as "map" in the URL. If this button is meant to be reused for other widget types, consider making it dynamic.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if this button is included in templates for other widget types
grep -r "{% include \"campaign/partials/edit_widget_button.html\" %}" --include="*.html" .
```

Length of output: 224

---



</details>

**Verified Usage: Map Widget Only**

The search confirms that `edit_widget_button.html` is only included in `dashboard_map.html`, indicating that its current hardcoding of `"map"` for the widget type is intentional. If you plan to repurpose this partial for other widget types later, you'll need to make the widget type dynamic (e.g., by passing it as a context variable). For now, no changes are necessary.

</details>
<details>
<summary>corehq/apps/campaign/static/campaign/js/dashboard.js (4)</summary>

`11-16`: **Good use of constants for improved maintainability**

Defining constants for selectors and text makes the code more maintainable. Using gettext for translatable strings is also good practice.

---

`27-29`: **Modal event handling setup**

The event handlers for the modal are properly set up. This approach ensures the modal title is set correctly depending on whether the user is adding or editing a widget.

---

`120-123`: **Modal cleanup handler**

The `onHideWidgetModal` function properly resets the modal state when it's closed, ensuring a clean state for the next use.

---

`125-132`: **Dynamic modal title based on context**

The `onShowWidgetModal` function correctly sets the modal title based on whether the user is adding or editing a widget, providing clear context to the user.

</details>
<details>
<summary>corehq/apps/campaign/views.py (6)</summary>

`7-7`: **Appropriate import for the new functionality.**

The addition of `get_object_or_404` is a good choice for handling widget retrieval, as it provides both existence validation and proper HTTP response handling.

---

`214-218`: **Good implementation of the widget retrieval logic.**

The conditional logic correctly differentiates between creating a new widget and editing an existing one. Using `get_object_or_404` ensures proper error handling if an invalid widget ID is provided.

---

`224-226`: **Clear form reset logic.**

The conditional form reset is appropriately implemented, ensuring that only newly created widgets result in an empty form being returned to the user.

---

`232-232`: **Proper widget inclusion in the context.**

Adding the widget to the context is necessary for the template to access widget-specific data when editing.

---

`240-250`: **Well-structured edit_widget method.**

The method follows the same pattern as the existing `new_widget` method, with proper validation and error handling. Including the widget in the context allows the form to be pre-populated with the widget's current values.

---

`252-257`: **Effective use of cached_property for widget_id.**

Using a cached property for widget_id avoids duplication and provides a consistent way to access the ID across different request methods.

</details>
<details>
<summary>corehq/apps/campaign/tests/test_views.py (5)</summary>

`170-172`: **Good use of class constants.**

Replacing hardcoded strings with constants improves maintainability and reduces the risk of typos in the tests.

---

`174-178`: **Proper test setup and cleanup.**

The `setUpClass` method creates the necessary dashboard object for all tests, and `addClassCleanup` ensures proper cleanup, preventing test pollution.

---

`193-197`: **Effective extraction of common assertions.**

The `_assert_for_success` static method reduces code duplication and ensures consistent validation across test methods.

---

`299-377`: **Comprehensive test coverage for map widget editing.**

The tests thoroughly verify both the form rendering and saving functionality for map widgets, including validation that the edited values are correctly persisted.

---

`379-404`: **Thorough validation of report widget editing.**

The test verifies all aspects of report widget editing, including validation that dashboard tab changes are properly saved.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T12:41:32Z
user: login: zandre-eng
body: Looks good from my side, just two minor comments.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:47:43Z
user: login: ajeety4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:03:32Z
user: login: mkangia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T15:59:57Z
user: login: mkangia
body: Looks good :+1: 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:01:01Z
user: login: ajeety4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:08:08Z
user: login: kaapstorm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:09:50Z
user: login: Charl1996
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T07:30:06Z
user: login: Charl1996
body: Looks good.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T07:32:12Z
user: login: ajeety4
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T08:23:01Z
user: login: zandre-eng
body: Looks good from my end!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T08:27:45Z

Review comments:
diff_hunk: @@ -8,6 +8,13 @@ import { Map, MapItem } from "geospatial/js/models";
 
 let mobileWorkerMapsInitialized = false;
 
+const widgetModalSelector = '#widget-modal';
+const modalTitleSelector = '.modal-title';
+const addWidgetText = gettext('Add Widget');
+const editWidgetText = gettext('Edit Widget');
+const loadingText = gettext("Loading...");
+let modalTitleElement = null;
path: corehq/apps/campaign/static/campaign/js/dashboard.js
user: login: zandre-eng
body: Nit: Could you rename this to `$modalTitleElement` to indicate that it's holding an actual element.
created_at: 2025-03-27T09:30:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,3 +131,17 @@ var htmxAfterSwapWidgetForm = function (event) {
         }, 2000);
     }
 };
+
+var onHideWidgetModal = function () {
+    $('#widget-modal-body').html(`<i class="fa-solid fa-spinner fa-spin"></i> ${loadingText}`);
path: corehq/apps/campaign/static/campaign/js/dashboard.js
user: login: zandre-eng
body: I remember from the base branch that this was changed so as to not recreate the HTML element each time. Will this be updated once the base branch is merged to master or will this be the final implementation?
created_at: 2025-03-27T11:18:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,3 +131,17 @@ var htmxAfterSwapWidgetForm = function (event) {
         }, 2000);
     }
 };
+
+var onHideWidgetModal = function () {
+    $('#widget-modal-body').html(`<i class="fa-solid fa-spinner fa-spin"></i> ${loadingText}`);
path: corehq/apps/campaign/static/campaign/js/dashboard.js
user: login: ajeety4
body: This will be updated once the base branch is merged to master
created_at: 2025-03-27T12:03:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,8 +19,14 @@
   hq-hx-action="save_widget"
   hx-post="{{ request.path_info }}"
   hx-target="#widget-form"
-  hx-swap="outerHTML"
-  hx-vals='{"widget_type": "{{ widget_type }}"}'
+  hx-swap="innerHTML"
+  hx-vals='{
+    "widget_type": "{{ widget_type }}"
+    {% if widget.id %}
+    , "widget_id": "{{ widget.id }}"
path: corehq/apps/campaign/templates/campaign/partials/widget_form.html
user: login: mkangia
body: that is a weird place for `,`
Will it fail if you do the following?
```
"widget_type": "{{ widget_type }}",
{% if widget.id %}
"widget_id": "{{ widget.id }}",
{% endif %}
}'
```
created_at: 2025-03-27T15:59:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,8 +19,14 @@
   hq-hx-action="save_widget"
   hx-post="{{ request.path_info }}"
   hx-target="#widget-form"
-  hx-swap="outerHTML"
-  hx-vals='{"widget_type": "{{ widget_type }}"}'
+  hx-swap="innerHTML"
+  hx-vals='{
+    "widget_type": "{{ widget_type }}"
+    {% if widget.id %}
+    , "widget_id": "{{ widget.id }}"
path: corehq/apps/campaign/templates/campaign/partials/widget_form.html
user: login: ajeety4
body: Good question.
You are right - It does fail because of the trailing comma after `widget_type` during the add widget operation where `widget.id` is not available.
created_at: 2025-03-27T16:08:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,7 +21,12 @@
     hx-post="{{ request.path_info }}"
     hx-target="#widget-form"
     hx-swap="outerHTML"
-    hx-vals='{"widget_type": "{{ widget_type }}"}'
+    hx-vals='{
+      "widget_type": "{{ widget_type }}"
+      {% if widget.id %}
+      , "widget_id": "{{ widget.id }}"
path: corehq/apps/campaign/templates/campaign/partials/widget_form.html
user: login: Charl1996
body: Nit: Can't we change this like below?
```
hq-vals='{
    "widget_type": "{{ widget_type }}",
    "widget_id": "{{ widget.id }}"
}'
``` 
We will then always pass in the `widget_id` key-value pair, but the value will be empty when not present and we do check for this in the back end.

It's just a personal preference which makes the code look cleaner, so feel free to ignore.
created_at: 2025-03-28T07:30:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,7 +21,12 @@
     hx-post="{{ request.path_info }}"
     hx-target="#widget-form"
     hx-swap="outerHTML"
-    hx-vals='{"widget_type": "{{ widget_type }}"}'
+    hx-vals='{
+      "widget_type": "{{ widget_type }}"
+      {% if widget.id %}
+      , "widget_id": "{{ widget.id }}"
path: corehq/apps/campaign/templates/campaign/partials/widget_form.html
user: login: ajeety4
body: Sounds good. https://github.com/dimagi/commcare-hq/pull/36041/commits/e803d2e7e43497c71c943fd9c28f33414ce3bd6f
created_at: 2025-03-28T08:23:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: ajeety4
created_at: 2025-03-21T12:37:40Z
body: @CodeRabbit review
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-03-21T12:41:29Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes introduce enhancements for widget management on the dashboard. On the client side, new JavaScript constants and functions are added to control the modal widget behavior. Event handlers for modal show and hide events update the modal title and loading state dynamically. HTML templates are revised to include an "edit widget" button and adjust the modal header and widget form, with the form now conditionally sending a widget ID. The view logic is updated so that when saving a widget, the presence of a widget ID determines whether an existing widget is retrieved (using a 404 check) or a new one is created. Additionally, tests now use predefined widget action constants, include a dedicated test suite for editing widgets, and refactor method names for clarity. These changes provide unified control flow for creating and editing widgets across the client and server.

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Browser
    participant DashboardJS
    participant Server

    User->>Browser: Click "Edit Widget" button
    Browser->>DashboardJS: Trigger show.bs.modal event
    DashboardJS->>Browser: Execute onShowWidgetModal (set modal title)
    Browser->>Server: hx-get request for widget form data
    Server-->>Browser: Return widget form data
    Browser->>DashboardJS: Populate modal with form
    User->>Browser: Submit updated widget form
    Browser->>Server: POST form data for widget save
    Server-->>Browser: Return save confirmation
```

```mermaid
sequenceDiagram
    participant Client
    participant DashboardWidgetView

    Client->>DashboardWidgetView: Call save_widget(widget_id?)
    alt widget_id exists
        DashboardWidgetView->>DashboardWidgetView: Retrieve widget (get_object_or_404)
    else
        DashboardWidgetView->>DashboardWidgetView: Create new widget instance
    end
    DashboardWidgetView->>DashboardWidgetView: Process form data
    DashboardWidgetView-->>Client: Return response (success/error)
```

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=dimagi/commcare-hq&utm_content=36041):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: ajeety4
created_at: 2025-03-21T14:11:46Z
body: NOTE: The edit button in the UI is not included for `reports` as the work for UI is currently in progress [here](https://dimagi.atlassian.net/browse/SC-4291?atlOrigin=eyJpIjoiNjZkMzBhNTIzODRiNGZhMTk2YTM1YTllNGM0M2UwNzkiLCJwIjoiamlyYS1zbGFjay1pbnQifQ). Post the completion of UI, the button can be included and should be a simple one line work.
author_association: CONTRIBUTOR
user: login: ajeety4
created_at: 2025-03-26T07:02:06Z
body: Note: I have kept the destination branch to [ay/add-dashboard-widget](https://github.com/dimagi/commcare-hq/tree/ay/add-dashboard-widget) for now tp facilitate easier review. It will be updated to master once the destination branch is merged.
author_association: CONTRIBUTOR
user: login: zandre-eng
created_at: 2025-03-27T14:45:44Z
body: @ajeety4 Happy to take a final pass through this PR once merge conflicts have been addressed
author_association: CONTRIBUTOR
user: login: ajeety4
created_at: 2025-03-27T15:38:06Z
body: > @ajeety4 Happy to take a final pass through this PR once merge conflicts have been addressed

Thanks Zandre. I have addressed the conflicts.
author_association: CONTRIBUTOR