diff --git a/alembic/versions/db161adbafdf_update_loa_to_array_type.py b/alembic/versions/db161adbafdf_update_loa_to_array_type.py new file mode 100644 index 00000000..acb4f9fd --- /dev/null +++ b/alembic/versions/db161adbafdf_update_loa_to_array_type.py @@ -0,0 +1,30 @@ +"""Update LOA to Array Type + +Revision ID: db161adbafdf +Revises: 6512aa8d4641 +Create Date: 2019-02-15 14:28:33.181136 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'db161adbafdf' +down_revision = '6512aa8d4641' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("ALTER TABLE task_orders ALTER COLUMN loa TYPE varchar[] USING array[loa]") + op.alter_column('task_orders', 'loa', new_column_name='loas') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("ALTER TABLE task_orders ALTER COLUMN loas TYPE varchar USING loas[1]") + op.alter_column('task_orders', 'loas', new_column_name='loa') + # ### end Alembic commands ### diff --git a/atst/forms/application.py b/atst/forms/application.py index d917f72d..80bb0858 100644 --- a/atst/forms/application.py +++ b/atst/forms/application.py @@ -15,8 +15,6 @@ class ApplicationForm(FlaskForm): class NewApplicationForm(ApplicationForm): - EMPTY_ENVIRONMENT_NAMES = ["", None] - environment_names = FieldList( StringField(label=translate("forms.application.environment_names_label")), validators=[ @@ -32,13 +30,3 @@ class NewApplicationForm(ApplicationForm): ), ], ) - - @property - def data(self): - _data = super(FlaskForm, self).data - _data["environment_names"] = [ - n - for n in _data["environment_names"] - if n not in self.EMPTY_ENVIRONMENT_NAMES - ] - return _data diff --git a/atst/forms/forms.py b/atst/forms/forms.py index 34c861f2..5d6d37c4 100644 --- a/atst/forms/forms.py +++ b/atst/forms/forms.py @@ -5,6 +5,8 @@ from atst.utils.flash import formatted_flash as flash class ValidatedForm(FlaskForm): + EMPTY_LIST_FIELD = ["", None] + def perform_extra_validation(self, *args, **kwargs): """Performs any applicable extra validation. Must return True if the form is valid or False otherwise.""" @@ -13,6 +15,11 @@ class ValidatedForm(FlaskForm): @property def data(self): _data = super().data + for field in _data: + if _data[field].__class__.__name__ == "list": + _data[field] = [ + el for el in _data[field] if el not in self.EMPTY_LIST_FIELD + ] _data.pop("csrf_token", None) return _data diff --git a/atst/forms/ko_review.py b/atst/forms/ko_review.py index aec28e7f..672cad2e 100644 --- a/atst/forms/ko_review.py +++ b/atst/forms/ko_review.py @@ -1,11 +1,10 @@ from flask_wtf.file import FileAllowed from wtforms.fields.html5 import DateField -from wtforms.fields import StringField, TextAreaField, FileField +from wtforms.fields import StringField, TextAreaField, FileField, FieldList from wtforms.validators import Optional, Length from .forms import CacheableForm -from .validators import IsNumber from atst.utils.localization import translate @@ -26,8 +25,8 @@ class KOReviewForm(CacheableForm): number = StringField( translate("forms.ko_review.to_number"), validators=[Length(min=10)] ) - loa = StringField( - translate("forms.ko_review.loa"), validators=[Length(min=10), IsNumber()] + loas = FieldList( + StringField(translate("forms.ko_review.loa"), validators=[Optional()]) ) custom_clauses = TextAreaField( translate("forms.ko_review.custom_clauses_label"), diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 73e090f9..b09eca73 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -90,7 +90,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): pdf_attachment_id = Column(ForeignKey("attachments.id")) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) number = Column(String, unique=True) # Task Order Number - loa = Column(String) # Line of Accounting (LOA) + loas = Column(ARRAY(String)) # Line of Accounting (LOA) custom_clauses = Column(String) # Custom Clauses signer_dod_id = Column(String) signed_at = Column(DateTime) diff --git a/js/components/date_selector.js b/js/components/date_selector.js index 1d7f49dd..e5e48128 100644 --- a/js/components/date_selector.js +++ b/js/components/date_selector.js @@ -9,10 +9,18 @@ var paddedNumber = function(number) { } } -export default Vue.component('date-selector', { - props: ['initialday', 'initialmonth', 'initialyear', 'mindate', 'maxdate'], +export default { + name: 'date-selector', - data() { + props: { + initialday: { type: String }, + initialmonth: { type: String }, + initialyear: { type: String }, + mindate: { type: String }, + maxdate: { type: String }, + }, + + data: function() { return { day: this.initialday, month: this.initialmonth, @@ -41,7 +49,7 @@ export default Vue.component('date-selector', { }, computed: { - formattedDate() { + formattedDate: function() { let day = paddedNumber(this.day) let month = paddedNumber(this.month) @@ -52,23 +60,23 @@ export default Vue.component('date-selector', { return `${month}/${day}/${this.year}` }, - isMonthValid() { + isMonthValid: function() { var _month = parseInt(this.month) return _month >= 0 && _month <= 12 }, - isDayValid() { + isDayValid: function() { var _day = parseInt(this.day) return _day >= 0 && _day <= this.daysMaxCalculation }, - isYearValid() { + isYearValid: function() { return parseInt(this.year) >= 1 }, - isWithinDateRange() { + isWithinDateRange: function() { let _mindate = this.mindate ? Date.parse(this.mindate) : null let _maxdate = this.maxdate ? Date.parse(this.maxdate) : null let _dateTimestamp = Date.UTC(this.year, this.month - 1, this.day) @@ -84,7 +92,7 @@ export default Vue.component('date-selector', { return true }, - isDateValid() { + isDateValid: function() { return ( this.day && this.month && @@ -96,7 +104,7 @@ export default Vue.component('date-selector', { ) }, - daysMaxCalculation() { + daysMaxCalculation: function() { switch (parseInt(this.month)) { case 2: // February if (this.year) { @@ -120,7 +128,7 @@ export default Vue.component('date-selector', { }, }, - render(createElement) { + render: function(createElement) { return createElement('p', 'Please implement inline-template') }, -}) +} diff --git a/js/components/forms/ko_review.js b/js/components/forms/ko_review.js new file mode 100644 index 00000000..c48ecd8f --- /dev/null +++ b/js/components/forms/ko_review.js @@ -0,0 +1,52 @@ +import textinput from '../text_input' +import DateSelector from '../date_selector' +import uploadinput from '../upload_input' +import inputValidations from '../../lib/input_validations' +import FormMixin from '../../mixins/form' + +const createLOA = number => ({ number }) + +export default { + name: 'ko-review', + + mixins: [FormMixin], + + components: { + textinput, + DateSelector, + uploadinput, + }, + + props: { + initialData: { + type: Object, + default: () => ({}), + }, + modalName: String, + }, + + data: function() { + const loa_list = this.initialData['loas'] + const loas = (loa_list.length > 0 ? loa_list : ['']).map(createLOA) + + return { + loas, + } + }, + + mounted: function() { + this.$root.$on('onLOAAdded', this.addLOA) + }, + + methods: { + addLOA: function(event) { + this.loas.push(createLOA('')) + }, + + removeLOA: function(index) { + if (this.loas.length > 1) { + this.loas.splice(index, 1) + } + }, + }, +} diff --git a/js/index.js b/js/index.js index dbc72654..654be95c 100644 --- a/js/index.js +++ b/js/index.js @@ -31,6 +31,7 @@ import ConfirmationPopover from './components/confirmation_popover' import { isNotInVerticalViewport } from './lib/viewport' import DateSelector from './components/date_selector' import SidenavToggler from './components/sidenav_toggler' +import KoReview from './components/forms/ko_review' Vue.config.productionTip = false @@ -64,6 +65,7 @@ const app = new Vue({ DateSelector, EditOfficerForm, SidenavToggler, + KoReview, }, mounted: function() { diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index b7727a77..7a982261 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -323,6 +323,53 @@ margin: 0; } } + + .task-order__loa-list { + ul { + padding-left: 0; + } + + + + .task-order__loa-add-item { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + max-width: 30em; + + .icon-link { + &:first-child { + margin-right: -$gap; + } + } + } + } + + .task-order__loa-list-item { + display: flex; + flex-direction: row; + align-items: flex-start; + position: relative; + + .usa-input { + flex-grow: 1; + } + + .loa-list-item__remover { + @include icon-link; + @include icon-link-vertical; + @include icon-link-color($color-red, $color-red-lightest); + + margin-bottom: 0; + margin-right: -$gap; + + position: absolute; + margin-top: 3 * $gap; + margin-left: $gap; + left: 35em; + max-width: 30em; + } + } } .task-order-invitations { diff --git a/templates/portfolios/task_orders/review.html b/templates/portfolios/task_orders/review.html index 92176252..de3d3b7a 100644 --- a/templates/portfolios/task_orders/review.html +++ b/templates/portfolios/task_orders/review.html @@ -11,87 +11,103 @@ {% from "components/review_field.html" import ReviewField %} {% from "components/upload_input.html" import UploadInput %} + {% block content %} + +
-
+ {% include "fragments/flash.html" %} - {% include "fragments/flash.html" %} - - {% block form_action %} -
- {% endblock %} - - {{ form.csrf_token }} - - {% block form %} - -
-

- {{ "task_orders.ko_review.title" | translate }} -

- {% include "fragments/ko_review_message.html" %} -
- -
- -
-

-
{{ "task_orders.ko_review.review_title" | translate }}
- {{ "task_orders.new.review.section_title"| translate }} -

-
- -
-
- {{ "task_orders.new.review.app_info"| translate }} -
- {% include "fragments/task_order_review/app_info.html" %} -
- -
- {{ "task_orders.new.review.reporting"| translate }} - {{ EditLink(url_for("task_orders.new", screen=1, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} -
- {% include "fragments/task_order_review/reporting.html" %} -
- -
- {{ "task_orders.new.review.funding"| translate }} - {{ EditLink(url_for("task_orders.new", screen=2, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} -
- {% include "fragments/task_order_review/funding.html" %} - -
- {{ DatePicker(form.start_date) }} - {{ DatePicker(form.end_date) }} -
-
- -
- {{ "task_orders.new.review.oversight"| translate }} - {{ EditLink(url_for("task_orders.new", screen=3, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} -
- {% include "fragments/task_order_review/oversight.html" %} -
- -
{{ "task_orders.ko_review.task_order_information"| translate }}
- -
- {{ UploadInput(form.pdf, show_label=True) }} - {{ TextInput(form.number, placeholder='1234567890') }} - {{ TextInput(form.loa, placeholder='1234567890') }} - {{ TextInput(form.custom_clauses, paragraph=True) }} -
- -
-
+ {% block form_action %} + {% endblock %} + {{ form.csrf_token }} + + {% block form %} + +
+

+ {{ "task_orders.ko_review.title" | translate }} +

+ {% include "fragments/ko_review_message.html" %} +
+ +
+
+ +
+ {{ "task_orders.new.review.app_info"| translate }} +
+ {% include "fragments/task_order_review/app_info.html" %} +
+ +
+ {{ "task_orders.new.review.reporting"| translate }} + {{ EditLink(url_for("task_orders.new", screen=1, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} +
+ {% include "fragments/task_order_review/reporting.html" %} +
+ +
+ {{ "task_orders.new.review.funding"| translate }} + {{ EditLink(url_for("task_orders.new", screen=2, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} +
+ {% include "fragments/task_order_review/funding.html" %} + +
+ {{ DatePicker(form.start_date) }} + {{ DatePicker(form.end_date) }} +
+
+ +
+ {{ "task_orders.new.review.oversight"| translate }} + {{ EditLink(url_for("task_orders.new", screen=3, task_order_id=task_order.id, _anchor="reporting", ko_edit=True)) }} +
+ {% include "fragments/task_order_review/oversight.html" %} +
+ +
{{ "task_orders.ko_review.task_order_information"| translate }}
+ +
+ {{ UploadInput(form.pdf, show_label=True) }} + {{ TextInput(form.number, placeholder='1234567890') }} + +
+
    +
  • +
    + + + +
    + +
  • +
+
+ +
+
+ + {{ TextInput(form.custom_clauses, paragraph=True) }} +
+ +
+
+ {% endblock %} +
-
+ -
+
+
{% endblock %} diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index f11727a8..0636df2e 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -358,7 +358,7 @@ def test_submit_completed_ko_review_page_as_cor(client, user_session, pdf_upload "start_date": "02/10/2019", "end_date": "03/10/2019", "number": "1938745981", - "loa": "0813458013405", + "loas-0": "0813458013405", "custom_clauses": "hi im a custom clause", "pdf": pdf_upload, } @@ -397,12 +397,15 @@ def test_submit_completed_ko_review_page_as_ko(client, user_session, pdf_upload) task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko) user_session(ko) + loa_list = ["123123123", "456456456", "789789789"] form_data = { "start_date": "02/10/2019", "end_date": "03/10/2019", "number": "1938745981", - "loa": "0813458013405", + "loas-0": loa_list[0], + "loas-1": loa_list[1], + "loas-2": loa_list[2], "custom_clauses": "hi im a custom clause", "pdf": pdf_upload, } @@ -419,6 +422,7 @@ def test_submit_completed_ko_review_page_as_ko(client, user_session, pdf_upload) assert response.headers["Location"] == url_for( "task_orders.signature_requested", task_order_id=task_order.id, _external=True ) + assert task_order.loas == loa_list def test_so_review_page(app, client, user_session):