Merge pull request #682 from dod-ccpo/multiple-loas

Adding Multiple LOA Inputs to KO Review Form
This commit is contained in:
leigh-mil 2019-03-01 11:46:39 -05:00 committed by GitHub
commit 7919dcdac8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 257 additions and 104 deletions

View File

@ -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 ###

View File

@ -15,8 +15,6 @@ class ApplicationForm(FlaskForm):
class NewApplicationForm(ApplicationForm): class NewApplicationForm(ApplicationForm):
EMPTY_ENVIRONMENT_NAMES = ["", None]
environment_names = FieldList( environment_names = FieldList(
StringField(label=translate("forms.application.environment_names_label")), StringField(label=translate("forms.application.environment_names_label")),
validators=[ 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

View File

@ -5,6 +5,8 @@ from atst.utils.flash import formatted_flash as flash
class ValidatedForm(FlaskForm): class ValidatedForm(FlaskForm):
EMPTY_LIST_FIELD = ["", None]
def perform_extra_validation(self, *args, **kwargs): def perform_extra_validation(self, *args, **kwargs):
"""Performs any applicable extra validation. Must """Performs any applicable extra validation. Must
return True if the form is valid or False otherwise.""" return True if the form is valid or False otherwise."""
@ -13,6 +15,11 @@ class ValidatedForm(FlaskForm):
@property @property
def data(self): def data(self):
_data = super().data _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) _data.pop("csrf_token", None)
return _data return _data

View File

@ -1,11 +1,10 @@
from flask_wtf.file import FileAllowed from flask_wtf.file import FileAllowed
from wtforms.fields.html5 import DateField 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 wtforms.validators import Optional, Length
from .forms import CacheableForm from .forms import CacheableForm
from .validators import IsNumber
from atst.utils.localization import translate from atst.utils.localization import translate
@ -26,8 +25,8 @@ class KOReviewForm(CacheableForm):
number = StringField( number = StringField(
translate("forms.ko_review.to_number"), validators=[Length(min=10)] translate("forms.ko_review.to_number"), validators=[Length(min=10)]
) )
loa = StringField( loas = FieldList(
translate("forms.ko_review.loa"), validators=[Length(min=10), IsNumber()] StringField(translate("forms.ko_review.loa"), validators=[Optional()])
) )
custom_clauses = TextAreaField( custom_clauses = TextAreaField(
translate("forms.ko_review.custom_clauses_label"), translate("forms.ko_review.custom_clauses_label"),

View File

@ -90,7 +90,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
pdf_attachment_id = Column(ForeignKey("attachments.id")) pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
number = Column(String, unique=True) # Task Order Number 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 custom_clauses = Column(String) # Custom Clauses
signer_dod_id = Column(String) signer_dod_id = Column(String)
signed_at = Column(DateTime) signed_at = Column(DateTime)

View File

@ -9,10 +9,18 @@ var paddedNumber = function(number) {
} }
} }
export default Vue.component('date-selector', { export default {
props: ['initialday', 'initialmonth', 'initialyear', 'mindate', 'maxdate'], name: 'date-selector',
data() { props: {
initialday: { type: String },
initialmonth: { type: String },
initialyear: { type: String },
mindate: { type: String },
maxdate: { type: String },
},
data: function() {
return { return {
day: this.initialday, day: this.initialday,
month: this.initialmonth, month: this.initialmonth,
@ -41,7 +49,7 @@ export default Vue.component('date-selector', {
}, },
computed: { computed: {
formattedDate() { formattedDate: function() {
let day = paddedNumber(this.day) let day = paddedNumber(this.day)
let month = paddedNumber(this.month) let month = paddedNumber(this.month)
@ -52,23 +60,23 @@ export default Vue.component('date-selector', {
return `${month}/${day}/${this.year}` return `${month}/${day}/${this.year}`
}, },
isMonthValid() { isMonthValid: function() {
var _month = parseInt(this.month) var _month = parseInt(this.month)
return _month >= 0 && _month <= 12 return _month >= 0 && _month <= 12
}, },
isDayValid() { isDayValid: function() {
var _day = parseInt(this.day) var _day = parseInt(this.day)
return _day >= 0 && _day <= this.daysMaxCalculation return _day >= 0 && _day <= this.daysMaxCalculation
}, },
isYearValid() { isYearValid: function() {
return parseInt(this.year) >= 1 return parseInt(this.year) >= 1
}, },
isWithinDateRange() { isWithinDateRange: function() {
let _mindate = this.mindate ? Date.parse(this.mindate) : null let _mindate = this.mindate ? Date.parse(this.mindate) : null
let _maxdate = this.maxdate ? Date.parse(this.maxdate) : null let _maxdate = this.maxdate ? Date.parse(this.maxdate) : null
let _dateTimestamp = Date.UTC(this.year, this.month - 1, this.day) let _dateTimestamp = Date.UTC(this.year, this.month - 1, this.day)
@ -84,7 +92,7 @@ export default Vue.component('date-selector', {
return true return true
}, },
isDateValid() { isDateValid: function() {
return ( return (
this.day && this.day &&
this.month && this.month &&
@ -96,7 +104,7 @@ export default Vue.component('date-selector', {
) )
}, },
daysMaxCalculation() { daysMaxCalculation: function() {
switch (parseInt(this.month)) { switch (parseInt(this.month)) {
case 2: // February case 2: // February
if (this.year) { 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') return createElement('p', 'Please implement inline-template')
}, },
}) }

View File

@ -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)
}
},
},
}

View File

@ -31,6 +31,7 @@ import ConfirmationPopover from './components/confirmation_popover'
import { isNotInVerticalViewport } from './lib/viewport' import { isNotInVerticalViewport } from './lib/viewport'
import DateSelector from './components/date_selector' import DateSelector from './components/date_selector'
import SidenavToggler from './components/sidenav_toggler' import SidenavToggler from './components/sidenav_toggler'
import KoReview from './components/forms/ko_review'
Vue.config.productionTip = false Vue.config.productionTip = false
@ -64,6 +65,7 @@ const app = new Vue({
DateSelector, DateSelector,
EditOfficerForm, EditOfficerForm,
SidenavToggler, SidenavToggler,
KoReview,
}, },
mounted: function() { mounted: function() {

View File

@ -323,6 +323,53 @@
margin: 0; 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 { .task-order-invitations {

View File

@ -11,9 +11,10 @@
{% from "components/review_field.html" import ReviewField %} {% from "components/review_field.html" import ReviewField %}
{% from "components/upload_input.html" import UploadInput %} {% from "components/upload_input.html" import UploadInput %}
{% block content %}
<div class="col task-order-form"> {% block content %}
<ko-review inline-template v-bind:initial-data='{{ form.data|tojson }}'>
<div class="col task-order-form">
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
@ -33,15 +34,8 @@
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel__heading">
<h1 class="task-order-form__heading subheading">
<div class="h2">{{ "task_orders.ko_review.review_title" | translate }}</div>
{{ "task_orders.new.review.section_title"| translate }}
</h1>
</div>
<div class="panel__content"> <div class="panel__content">
<div class="h2"> <div class="h2">
{{ "task_orders.new.review.app_info"| translate }} {{ "task_orders.new.review.app_info"| translate }}
</div> </div>
@ -79,7 +73,28 @@
<div class="form__sub-fields"> <div class="form__sub-fields">
{{ UploadInput(form.pdf, show_label=True) }} {{ UploadInput(form.pdf, show_label=True) }}
{{ TextInput(form.number, placeholder='1234567890') }} {{ TextInput(form.number, placeholder='1234567890') }}
{{ TextInput(form.loa, placeholder='1234567890') }}
<div class="task-order__loa-list">
<ul>
<li v-for="(loa, i) in loas" class="task-order__loa-list-item">
<div class="usa-input usa-input--validation--anything">
<label :for="'loas-' + i">
<div class="usa-input__title" v-html="'Line of Accounting (LOA) #' + (i + 1)"></div>
</label>
<input type="text" v-model='loa.number' :id="'loas-' + i" placeholder="1234567890"/>
<input type="hidden" :name="'loas-' + i" v-model='loa.number'/>
</div>
<button v-on:click="removeLOA(i)" v-if="loas.length > 1" type="button" class='loa-list-item__remover'>
{{ Icon('trash') }}
<span>Remove</span>
</button>
</li>
</ul>
<div class="task-order__loa-add-item">
<button v-on:click="addLOA" class="icon-link" tabindex="0" type="button">{{ Icon('plus') }} Add another LOA</button>
</div>
</div>
{{ TextInput(form.custom_clauses, paragraph=True) }} {{ TextInput(form.custom_clauses, paragraph=True) }}
</div> </div>
@ -93,5 +108,6 @@
</form> </form>
</div> </div>
</ko-review>
{% endblock %} {% endblock %}

View File

@ -358,7 +358,7 @@ def test_submit_completed_ko_review_page_as_cor(client, user_session, pdf_upload
"start_date": "02/10/2019", "start_date": "02/10/2019",
"end_date": "03/10/2019", "end_date": "03/10/2019",
"number": "1938745981", "number": "1938745981",
"loa": "0813458013405", "loas-0": "0813458013405",
"custom_clauses": "hi im a custom clause", "custom_clauses": "hi im a custom clause",
"pdf": pdf_upload, "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) task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko)
user_session(ko) user_session(ko)
loa_list = ["123123123", "456456456", "789789789"]
form_data = { form_data = {
"start_date": "02/10/2019", "start_date": "02/10/2019",
"end_date": "03/10/2019", "end_date": "03/10/2019",
"number": "1938745981", "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", "custom_clauses": "hi im a custom clause",
"pdf": pdf_upload, "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( assert response.headers["Location"] == url_for(
"task_orders.signature_requested", task_order_id=task_order.id, _external=True "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): def test_so_review_page(app, client, user_session):