Merge pull request #682 from dod-ccpo/multiple-loas
Adding Multiple LOA Inputs to KO Review Form
This commit is contained in:
commit
7919dcdac8
30
alembic/versions/db161adbafdf_update_loa_to_array_type.py
Normal file
30
alembic/versions/db161adbafdf_update_loa_to_array_type.py
Normal 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 ###
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"),
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
52
js/components/forms/ko_review.js
Normal file
52
js/components/forms/ko_review.js
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -11,87 +11,103 @@
|
||||
{% from "components/review_field.html" import ReviewField %}
|
||||
{% from "components/upload_input.html" import UploadInput %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<ko-review inline-template v-bind:initial-data='{{ form.data|tojson }}'>
|
||||
<div class="col task-order-form">
|
||||
|
||||
<div class="col task-order-form">
|
||||
{% include "fragments/flash.html" %}
|
||||
|
||||
{% include "fragments/flash.html" %}
|
||||
|
||||
{% block form_action %}
|
||||
<form method='POST' action="{{ url_for('portfolios.submit_ko_review', portfolio_id=portfolio.id, task_order_id=task_order.id, form=form) }}" autocomplete="off" enctype="multipart/form-data">
|
||||
{% endblock %}
|
||||
|
||||
{{ form.csrf_token }}
|
||||
|
||||
{% block form %}
|
||||
|
||||
<div class="top-message">
|
||||
<h1 class="subheading title">
|
||||
{{ "task_orders.ko_review.title" | translate }}
|
||||
</h1>
|
||||
{% include "fragments/ko_review_message.html" %}
|
||||
</div>
|
||||
|
||||
<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="h2">
|
||||
{{ "task_orders.new.review.app_info"| translate }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/app_info.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/reporting.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/funding.html" %}
|
||||
|
||||
<div class="form__sub-fields">
|
||||
{{ DatePicker(form.start_date) }}
|
||||
{{ DatePicker(form.end_date) }}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/oversight.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">{{ "task_orders.ko_review.task_order_information"| translate }}</div>
|
||||
|
||||
<div class="form__sub-fields">
|
||||
{{ UploadInput(form.pdf, show_label=True) }}
|
||||
{{ TextInput(form.number, placeholder='1234567890') }}
|
||||
{{ TextInput(form.loa, placeholder='1234567890') }}
|
||||
{{ TextInput(form.custom_clauses, paragraph=True) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% block form_action %}
|
||||
<form method='POST' action="{{ url_for('portfolios.submit_ko_review', portfolio_id=portfolio.id, task_order_id=task_order.id, form=form) }}" autocomplete="off" enctype="multipart/form-data">
|
||||
{% endblock %}
|
||||
|
||||
{{ form.csrf_token }}
|
||||
|
||||
{% block form %}
|
||||
|
||||
<div class="top-message">
|
||||
<h1 class="subheading title">
|
||||
{{ "task_orders.ko_review.title" | translate }}
|
||||
</h1>
|
||||
{% include "fragments/ko_review_message.html" %}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__content">
|
||||
|
||||
<div class="h2">
|
||||
{{ "task_orders.new.review.app_info"| translate }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/app_info.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/reporting.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/funding.html" %}
|
||||
|
||||
<div class="form__sub-fields">
|
||||
{{ DatePicker(form.start_date) }}
|
||||
{{ DatePicker(form.end_date) }}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="h2">
|
||||
{{ "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)) }}
|
||||
</div>
|
||||
{% include "fragments/task_order_review/oversight.html" %}
|
||||
<hr>
|
||||
|
||||
<div class="h2">{{ "task_orders.ko_review.task_order_information"| translate }}</div>
|
||||
|
||||
<div class="form__sub-fields">
|
||||
{{ UploadInput(form.pdf, show_label=True) }}
|
||||
{{ TextInput(form.number, 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) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<div class='action-group'>
|
||||
<input type='submit' class='usa-button usa-button-primary' value='Continue' />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ko-review>
|
||||
{% endblock %}
|
||||
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user