Skip to content

Commit 011e57c

Browse files
authored
Merge pull request dimagi#1011 from dimagi/ze/create-task-modal
Create Task Modal
2 parents c9e1a7e + 310776c commit 011e57c

File tree

4 files changed

+150
-0
lines changed

4 files changed

+150
-0
lines changed

commcare_connect/opportunity/forms.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
OpportunityVerificationFlags,
3838
PaymentInvoice,
3939
PaymentUnit,
40+
Task,
4041
UserVisit,
4142
VisitReviewStatus,
4243
VisitValidationStatus,
@@ -1846,3 +1847,47 @@ def line_items(self):
18461847
"""
18471848
),
18481849
)
1850+
1851+
1852+
class CreateTaskForm(forms.Form):
1853+
task = forms.ModelChoiceField(
1854+
label=_("Task"),
1855+
queryset=Task.objects.none(),
1856+
empty_label=_("Select a task"),
1857+
widget=forms.Select(attrs={"data-tomselect": "1"}),
1858+
)
1859+
connect_worker = forms.ModelChoiceField(
1860+
label=_("Connect Worker"),
1861+
queryset=User.objects.none(),
1862+
empty_label=_("Select a Connect Worker"),
1863+
widget=forms.Select(attrs={"data-tomselect": "1"}),
1864+
)
1865+
due_date = forms.DateField(
1866+
label=_("Due Date"),
1867+
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
1868+
)
1869+
1870+
def __init__(self, *args, opportunity=None, **kwargs):
1871+
super().__init__(*args, **kwargs)
1872+
if opportunity is not None:
1873+
self.fields["task"].queryset = Task.objects.filter(app=opportunity.deliver_app)
1874+
self.fields["connect_worker"].queryset = User.objects.filter(
1875+
opportunityaccess__opportunity=opportunity,
1876+
opportunityaccess__accepted=True,
1877+
opportunityaccess__suspended=False,
1878+
)
1879+
self.fields["due_date"].widget.attrs["min"] = datetime.date.today().isoformat()
1880+
1881+
self.helper = FormHelper(self)
1882+
self.helper.form_tag = False
1883+
self.helper.layout = Layout(
1884+
Field("task"),
1885+
Field("connect_worker"),
1886+
Field("due_date"),
1887+
)
1888+
1889+
def clean_due_date(self):
1890+
due_date = self.cleaned_data["due_date"]
1891+
if due_date < datetime.date.today():
1892+
raise ValidationError(_("Due date cannot be in the past."))
1893+
return due_date

commcare_connect/opportunity/tests/factories.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,17 @@ class Meta:
267267
model = "opportunity.PaymentInvoice"
268268

269269

270+
class TaskFactory(DjangoModelFactory):
271+
app = SubFactory(CommCareAppFactory)
272+
slug = Faker("slug")
273+
name = Faker("name")
274+
description = Faker("text")
275+
time_estimate = Faker("pyint", min_value=1, max_value=8)
276+
277+
class Meta:
278+
model = "opportunity.Task"
279+
280+
270281
class ExchangeRateFactory(DjangoModelFactory):
271282
currency_code = "USD"
272283
rate = 1.0

commcare_connect/opportunity/tests/test_forms.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from commcare_connect.opportunity.forms import (
1111
AddBudgetNewUsersForm,
1212
AutomatedPaymentInvoiceForm,
13+
CreateTaskForm,
1314
OpportunityChangeForm,
1415
OpportunityInitUpdateForm,
1516
)
@@ -27,6 +28,7 @@
2728
OpportunityFactory,
2829
PaymentInvoiceFactory,
2930
PaymentUnitFactory,
31+
TaskFactory,
3032
)
3133
from commcare_connect.program.tests.factories import ManagedOpportunityFactory, ProgramFactory
3234

@@ -767,3 +769,32 @@ def test_readonly_form_with_line_items_table(self, valid_opportunity):
767769
)
768770

769771
assert form.line_items_table == mock_table
772+
773+
774+
@pytest.mark.django_db
775+
class TestCreateTaskForm:
776+
@pytest.fixture
777+
def task(self, opportunity):
778+
return TaskFactory(app=opportunity.deliver_app)
779+
780+
def test_invalid_past_date(self, opportunity, task):
781+
access = OpportunityAccessFactory(opportunity=opportunity, accepted=True, suspended=False)
782+
data = {
783+
"task": task.pk,
784+
"connect_worker": access.user.pk,
785+
"due_date": (datetime.date.today() - datetime.timedelta(days=1)).isoformat(),
786+
}
787+
form = CreateTaskForm(data, opportunity=opportunity)
788+
assert not form.is_valid()
789+
assert "due_date" in form.errors
790+
791+
def test_flw_queryset_filtering(self, opportunity):
792+
active = OpportunityAccessFactory(opportunity=opportunity, accepted=True, suspended=False).user
793+
unaccepted = OpportunityAccessFactory(opportunity=opportunity, accepted=False, suspended=False).user
794+
suspended = OpportunityAccessFactory(opportunity=opportunity, accepted=True, suspended=True).user
795+
796+
flw_queryset = CreateTaskForm(opportunity=opportunity).fields["connect_worker"].queryset
797+
798+
assert active in flw_queryset
799+
assert unaccepted not in flw_queryset
800+
assert suspended not in flw_queryset
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{% load i18n %}
2+
{% load crispy_forms_tags %}
3+
{% load static %}
4+
5+
<link rel="stylesheet" href="{% static 'bundles/css/tomselect.css' %}">
6+
<script src="{% static 'bundles/js/tomselect-bundle.js' %}" defer></script>
7+
<script>
8+
document.addEventListener('htmx:afterRequest', function (event) {
9+
if (event.detail.successful && event.detail.elt.id === 'create-task-form') {
10+
const form = document.getElementById('create-task-form');
11+
form.reset();
12+
form.querySelectorAll('[data-tomselect]').forEach(el => el.tomselect?.clear());
13+
window.dispatchEvent(new CustomEvent('create-task-success'));
14+
}
15+
});
16+
</script>
17+
18+
<div x-show="{{ modal_name }}" x-cloak x-transition.opacity class="modal-backdrop" role="dialog" aria-modal="true"
19+
aria-labelledby="new-task-modal-title"
20+
@keydown.escape.window="{{ modal_name }} = false"
21+
@create-task-success.window="{{ modal_name }} = false">
22+
<div @click.outside="{{ modal_name }} = false" class="modal">
23+
24+
<div class="flex justify-between items-start mb-4">
25+
<div>
26+
<div class="flex items-center gap-3 mb-1">
27+
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-indigo-100 shrink-0">
28+
<i class="fa-solid fa-list-check" aria-hidden="true"></i>
29+
</div>
30+
<h2 id="new-task-modal-title" class="text-xl font-bold text-brand-deep-purple">
31+
{% trans "Create Task" %}
32+
</h2>
33+
</div>
34+
<p class="text-sm text-gray-500 mt-1">
35+
{% trans "Create a new task, and assign it to the relevant Connect Worker." %}
36+
</p>
37+
</div>
38+
<button type="button" class="button-icon" @click="{{ modal_name }} = false"
39+
aria-label="{% trans 'Close dialog' %}">
40+
<i class="fa-solid fa-xmark cursor-pointer" aria-hidden="true"></i>
41+
</button>
42+
</div>
43+
44+
<form id="create-task-form"
45+
method="post"
46+
action="{{ create_task_url }}"
47+
hx-post="{{ create_task_url }}"
48+
hx-target="#create-task-form"
49+
hx-swap="outerHTML">
50+
{% csrf_token %}
51+
{% crispy form %}
52+
<div class="flex justify-between gap-3 pt-2">
53+
<button type="button" @click="{{ modal_name }} = false"
54+
class="button button-md outline-style flex-1">
55+
{% trans "Cancel" %}
56+
</button>
57+
<button type="submit" class="button button-md primary-dark flex-1">
58+
{% trans "Save Changes" %}
59+
</button>
60+
</div>
61+
</form>
62+
</div>
63+
</div>

0 commit comments

Comments
 (0)