diff --git a/atst/forms/officers.py b/atst/forms/officers.py new file mode 100644 index 00000000..84e2283a --- /dev/null +++ b/atst/forms/officers.py @@ -0,0 +1,60 @@ +from flask_wtf import FlaskForm +from wtforms.fields import FormField, StringField +from wtforms.fields.html5 import TelField +from wtforms.validators import Length, Optional + +from atst.forms.validators import IsNumber, PhoneNumber + +from .forms import CacheableForm + + +class OfficerForm(FlaskForm): + first_name = StringField("First Name") + last_name = StringField("Last Name") + email = StringField("Email") + phone_number = TelField("Phone Number", validators=[PhoneNumber()]) + dod_id = StringField("DoD ID", validators=[Optional(), Length(min=10), IsNumber()]) + + +class EditTaskOrderOfficersForm(CacheableForm): + + contracting_officer = FormField(OfficerForm) + contracting_officer_representative = FormField(OfficerForm) + security_officer = FormField(OfficerForm) + + OFFICER_PREFIXES = { + "contracting_officer": "ko", + "contracting_officer_representative": "cor", + "security_officer": "so", + } + OFFICER_INFO_FIELD_NAMES = [ + "first_name", + "last_name", + "email", + "phone_number", + "dod_id", + ] + + def process(self, formdata=None, obj=None, data=None, **kwargs): + if obj: + for name, field in self._fields.items(): + if name in self.OFFICER_PREFIXES: + prefix = self.OFFICER_PREFIXES[name] + officer_data = { + field_name: getattr(obj, prefix + "_" + field_name) + for field_name in self.OFFICER_INFO_FIELD_NAMES + } + field.process(formdata=formdata, data=officer_data) + else: + field.process(formdata) + else: + super(EditTaskOrderOfficersForm, self).process( + formdata=formdata, obj=obj, data=data, **kwargs + ) + + def populate_obj(self, obj): + for name, field in self._fields.items(): + if name in self.OFFICER_PREFIXES: + prefix = self.OFFICER_PREFIXES[name] + for field_name in self.OFFICER_INFO_FIELD_NAMES: + setattr(obj, prefix + "_" + field_name, field[field_name].data) diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 8bcb4715..61a6c9a7 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -1,11 +1,13 @@ from collections import defaultdict from operator import itemgetter -from flask import g, render_template, url_for +from flask import g, redirect, render_template, url_for, request as http_request from . import portfolios_bp +from atst.database import db from atst.domain.task_orders import TaskOrders from atst.domain.portfolios import Portfolios +from atst.forms.officers import EditTaskOrderOfficersForm from atst.models.task_order import Status as TaskOrderStatus @@ -69,8 +71,40 @@ def view_task_order(portfolio_id, task_order_id): def task_order_invitations(portfolio_id, task_order_id): portfolio = Portfolios.get(g.current_user, portfolio_id) task_order = TaskOrders.get(g.current_user, task_order_id) + form = EditTaskOrderOfficersForm(obj=task_order) return render_template( "portfolios/task_orders/invitations.html", portfolio=portfolio, task_order=task_order, + form=form, ) + + +@portfolios_bp.route( + "/portfolios//task_order//invitations", + methods=["POST"], +) +def edit_task_order_invitations(portfolio_id, task_order_id): + portfolio = Portfolios.get(g.current_user, portfolio_id) + task_order = TaskOrders.get(g.current_user, task_order_id) + form = EditTaskOrderOfficersForm(formdata=http_request.form, obj=task_order) + + if form.validate(): + form.populate_obj(task_order) + db.session.add(task_order) + db.session.commit() + + return redirect( + url_for( + "portfolios.task_order_invitations", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ) + ) + else: + return render_template( + "portfolios/task_orders/invitations.html", + portfolio=portfolio, + task_order=task_order, + form=form, + ) diff --git a/js/components/forms/__tests__/edit_officer_form.test.js b/js/components/forms/__tests__/edit_officer_form.test.js new file mode 100644 index 00000000..b5b02aa2 --- /dev/null +++ b/js/components/forms/__tests__/edit_officer_form.test.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils' + +import EditOfficerForm from '../edit_officer_form' + +describe('EditOfficerForm', () => { + it('defaults to not editing', () => { + const wrapper = shallowMount(EditOfficerForm) + expect(wrapper.vm.$data.editing).toEqual(false) + }) + + it('does not start in editing mode when no errors', () => { + const wrapper = shallowMount(EditOfficerForm, { + propsData: { hasErrors: false }, + }) + expect(wrapper.vm.$data.editing).toEqual(false) + }) + + it('does start in editing mode when the form has errors', () => { + const wrapper = shallowMount(EditOfficerForm, { + propsData: { hasErrors: true }, + }) + expect(wrapper.vm.$data.editing).toEqual(true) + }) + + it('starts editing when edit method called', () => { + const wrapper = shallowMount(EditOfficerForm) + wrapper.vm.edit({ preventDefault: () => null }) + expect(wrapper.vm.$data.editing).toEqual(true) + }) + + it('stops editing when cancel method called', () => { + const wrapper = shallowMount(EditOfficerForm) + wrapper.vm.cancel() + expect(wrapper.vm.$data.editing).toEqual(false) + }) +}) diff --git a/js/components/forms/edit_officer_form.js b/js/components/forms/edit_officer_form.js new file mode 100644 index 00000000..a8e4311b --- /dev/null +++ b/js/components/forms/edit_officer_form.js @@ -0,0 +1,40 @@ +import FormMixin from '../../mixins/form' +import checkboxinput from '../checkbox_input' +import textinput from '../text_input' + +export default { + name: 'edit-officer-form', + + mixins: [FormMixin], + + components: { + checkboxinput, + textinput, + }, + + props: { + hasErrors: { + type: Boolean, + default: () => false, + }, + }, + + data: function() { + return { + editing: this.hasErrors, + } + }, + + methods: { + edit: function(event) { + event.preventDefault() + this.editing = true + }, + + cancel: function(event) { + this.editing = false + }, + }, + + template: '
', +} diff --git a/js/index.js b/js/index.js index b12de788..f9673744 100644 --- a/js/index.js +++ b/js/index.js @@ -11,6 +11,7 @@ import multicheckboxinput from './components/multi_checkbox_input' import textinput from './components/text_input' import checkboxinput from './components/checkbox_input' import DetailsOfUse from './components/forms/details_of_use' +import EditOfficerForm from './components/forms/edit_officer_form' import poc from './components/forms/poc' import oversight from './components/forms/oversight' import financial from './components/forms/financial' @@ -64,6 +65,7 @@ const app = new Vue({ ConfirmationPopover, funding, DateSelector, + EditOfficerForm, }, mounted: function() { diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 0eb32e22..d268a344 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -343,6 +343,23 @@ } } + .officer__form { + .officer__form--actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + + .usa-button { + margin-left: 4 * $gap; + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + } + } + .officer__actions { margin-left: -2 * $gap; diff --git a/templates/portfolios/task_orders/invitations.html b/templates/portfolios/task_orders/invitations.html index 0ebad204..7fda4f2d 100644 --- a/templates/portfolios/task_orders/invitations.html +++ b/templates/portfolios/task_orders/invitations.html @@ -1,76 +1,125 @@ {% extends "portfolios/base.html" %} +{% from "components/checkbox_input.html" import CheckboxInput %} {% from "components/icon.html" import Icon %} +{% from "components/text_input.html" import TextInput %} -{% macro Link(text, icon_name, url='#', classes='') %} - +{% macro Link(text, icon_name, onClick=None, url='#', classes='') %} + {{ Icon(icon_name) }} {{ text }} {% endmacro %} -{% macro OfficerInfo(task_order, officer_type) %} -
+{% macro EditOfficerInfo(form, officer_type) -%} +
+ +
+{% endmacro %} + +{% macro OfficerInfo(task_order, officer_type, form) %} +

{{ ("task_orders.invitations." + officer_type + ".title") | translate }}

{{ ("task_orders.invitations." + officer_type + ".description") | translate }}

- {% set prefix = { "contracting_officer": "ko", "contracting_officer_representative": "cor", "security_officer": "so" }[officer_type] %} - {% set first_name = task_order[prefix + "_first_name"] %} - {% set last_name = task_order[prefix + "_last_name"] %} - {% set email = task_order[prefix + "_email"] %} - {% set phone_number = task_order[prefix + "_phone_number"] %} - {% set dod_id = task_order[prefix + "_dod_id"] %} + +
- {% if task_order[officer_type] %} -
-
-
{{ first_name }} {{ last_name }}
-
- {{ Icon("ok", classes="invited") }} - Invited + {% set prefix = { "contracting_officer": "ko", "contracting_officer_representative": "cor", "security_officer": "so" }[officer_type] %} + {% set first_name = task_order[prefix + "_first_name"] %} + {% set last_name = task_order[prefix + "_last_name"] %} + {% set email = task_order[prefix + "_email"] %} + {% set phone_number = task_order[prefix + "_phone_number"] %} + {% set dod_id = task_order[prefix + "_dod_id"] %} + + {% if task_order[officer_type] %} +
+
+
{{ first_name }} {{ last_name }}
+
+ {{ Icon("ok", classes="invited") }} + Invited +
+ +

{{ phone_number | usPhone }}

+

{{ "task_orders.invitations.dod_id_label" | translate}}: {{ dod_id }}

- -

{{ phone_number | usPhone }}

-

{{ "task_orders.invitations.dod_id_label" | translate}}: {{ dod_id }}

-
-
- {{ Link("Update", "edit") }} - {{ Link("Resend Invitation", "avatar") }} - {{ Link("Remove", "trash", classes="remove") }} -
- {% elif first_name and last_name %} -
-
-
{{ first_name }} {{ last_name }}
+
+ {{ Link("Update", "edit", onClick="edit") }} + {{ Link("Resend Invitation", "avatar") }} + {{ Link("Remove", "trash", classes="remove") }} +
+ {% elif first_name and last_name %} +
+
+
{{ first_name }} {{ last_name }}
+
+ {{ Icon("alert", classes="uninvited") }} + Not Invited +
+
+ +

{{ phone_number | usPhone }}

+
+
+ {{ Link("Update", "edit", onClick="edit") }} + {{ Link("Remove", "trash", classes="remove") }} + +
+ {% else %} +
{{ Icon("alert", classes="uninvited") }} - Not Invited + Not specified
- -

{{ phone_number | usPhone }}

-
-
- {{ Link("Update", "edit") }} - {{ Link("Remove", "trash", classes="remove") }} - -
- {% else %} -
-
- {{ Icon("alert", classes="uninvited") }} - Not specified +
+
+ {% endif %} + + {{ EditOfficerInfo(form, officer_type) }}
-
- -
- {% endif %} +
{% endmacro %} @@ -78,17 +127,21 @@
{% include "fragments/flash.html" %} -
-
-

-
Edit Task Order
- Oversight -

-
+
+ {{ form.csrf_token }} - {% for officer in ["contracting_officer", "contracting_officer_representative", "security_officer"] %} - {{ OfficerInfo(task_order, officer) }} - {% endfor %} -
+
+
+

+
Edit Task Order
+ Oversight +

+
+ + {% for officer in ["contracting_officer", "contracting_officer_representative", "security_officer"] %} + {{ OfficerInfo(task_order, officer, form[officer]) }} + {% endfor %} +
+
{% endblock %} diff --git a/tests/forms/test_officers.py b/tests/forms/test_officers.py new file mode 100644 index 00000000..ce6ca1eb --- /dev/null +++ b/tests/forms/test_officers.py @@ -0,0 +1,56 @@ +from werkzeug.datastructures import ImmutableMultiDict + +from atst.forms.officers import EditTaskOrderOfficersForm +from tests.factories import TaskOrderFactory, UserFactory + + +class TestEditTaskOrderOfficersForm: + def _assert_officer_info_matches(self, form, task_order, officer): + prefix = form.OFFICER_PREFIXES[officer] + + for field in form.OFFICER_INFO_FIELD_NAMES: + assert form[officer][field].data == getattr( + task_order, "{}_{}".format(prefix, field) + ) + + def test_processing_with_existing_task_order(self): + task_order = TaskOrderFactory.create() + form = EditTaskOrderOfficersForm(obj=task_order) + for officer in form.OFFICER_PREFIXES.keys(): + self._assert_officer_info_matches(form, task_order, officer) + + def test_processing_form_with_formdata(self): + data = { + "contracting_officer-first_name": "Han", + "contracting_officer-last_name": "Solo", + } + formdata = ImmutableMultiDict(data) + task_order = TaskOrderFactory.create() + form = EditTaskOrderOfficersForm(formdata=formdata, obj=task_order) + + for officer in ["contracting_officer_representative", "security_officer"]: + self._assert_officer_info_matches(form, task_order, officer) + + prefix = "ko" + officer = "contracting_officer" + for field in form.OFFICER_INFO_FIELD_NAMES: + data_field = "{}-{}".format(officer, field) + if data_field in formdata: + assert form[officer][field].data == formdata[data_field] + else: + assert form[officer][field].data == getattr( + task_order, "{}_{}".format(prefix, field) + ) + + def test_populate_obj(self): + data = { + "security_officer-first_name": "Luke", + "security_officer-last_name": "Skywalker", + } + formdata = ImmutableMultiDict(data) + task_order = TaskOrderFactory.create() + form = EditTaskOrderOfficersForm(formdata=formdata, obj=task_order) + + form.populate_obj(task_order) + assert task_order.so_first_name == data["security_officer-first_name"] + assert task_order.so_last_name == data["security_officer-last_name"] diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index 6bebd3aa..ed2f1c82 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -2,6 +2,7 @@ from flask import url_for import pytest from atst.domain.roles import Roles +from atst.domain.task_orders import TaskOrders from atst.models.portfolio_role import Status as PortfolioStatus from tests.factories import ( @@ -100,3 +101,53 @@ def test_can_view_task_order_invitations(client, user_session): ) ) assert response.status_code == 200 + + +class TestTaskOrderInvitations: + def setup(self): + self.portfolio = PortfolioFactory.create() + self.task_order = TaskOrderFactory.create(portfolio=self.portfolio) + + def _post(self, client, updates): + return client.post( + url_for( + "portfolios.edit_task_order_invitations", + portfolio_id=self.portfolio.id, + task_order_id=self.task_order.id, + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=updates, + ) + + def test_editing_with_partial_data(self, user_session, client): + user_session(self.portfolio.owner) + response = self._post( + client, + { + "contracting_officer-first_name": "Luke", + "contracting_officer-last_name": "Skywalker", + "security_officer-first_name": "Boba", + "security_officer-last_name": "Fett", + }, + ) + updated_task_order = TaskOrders.get(self.portfolio.owner, self.task_order.id) + assert updated_task_order.ko_first_name == "Luke" + assert updated_task_order.ko_last_name == "Skywalker" + assert updated_task_order.so_first_name == "Boba" + assert updated_task_order.so_last_name == "Fett" + + def test_editing_with_invalid_data(self, user_session, client): + user_session(self.portfolio.owner) + response = self._post( + client, + { + "contracting_officer-phone_number": "invalid input", + "security_officer-first_name": "Boba", + "security_officer-last_name": "Fett", + }, + ) + + assert "There were some errors" in response.data.decode() + + updated_task_order = TaskOrders.get(self.portfolio.owner, self.task_order.id) + assert updated_task_order.so_first_name != "Boba"