Merge pull request #284 from dod-ccpo/ui/request-approval-screen

Ui/request approval screen
This commit is contained in:
dandds 2018-09-17 09:18:12 -04:00 committed by GitHub
commit dffd0e9e0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 293 additions and 147 deletions

View File

@ -7,7 +7,10 @@ from .validators import Alphabet, PhoneNumber
class CCPOReviewForm(ValidatedForm): class CCPOReviewForm(ValidatedForm):
comment = TextAreaField("Comments (optional)") comment = TextAreaField(
"Instructions or comments",
description="Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.",
)
fname_mao = StringField( fname_mao = StringField(
"First Name (optional)", validators=[Optional(), Alphabet()] "First Name (optional)", validators=[Optional(), Alphabet()]
) )

View File

@ -5,4 +5,8 @@ from .forms import ValidatedForm
class InternalCommentForm(ValidatedForm): class InternalCommentForm(ValidatedForm):
text = TextAreaField(validators=[Optional()]) text = TextAreaField(
"CCPO Internal Notes",
description="You may add additional comments and notes for internal CCPO reference and follow-up here.",
validators=[Optional()],
)

View File

@ -8,7 +8,7 @@ class RequestReview(Base):
__tablename__ = "request_reviews" __tablename__ = "request_reviews"
id = Column(BigInteger, primary_key=True) id = Column(BigInteger, primary_key=True)
status = relationship("RequestStatusEvent", back_populates="review") status = relationship("RequestStatusEvent", uselist=False, back_populates="review")
user_id = Column(ForeignKey("users.id"), nullable=False) user_id = Column(ForeignKey("users.id"), nullable=False)
reviewer = relationship("User") reviewer = relationship("User")

View File

@ -48,6 +48,8 @@ class RequestStatusEvent(Base):
def log_name(self): def log_name(self):
if self.new_status == RequestStatus.CHANGES_REQUESTED: if self.new_status == RequestStatus.CHANGES_REQUESTED:
return "Denied" return "Denied"
if self.new_status == RequestStatus.CHANGES_REQUESTED_TO_FINVER:
return "Denied"
elif self.new_status == RequestStatus.PENDING_FINANCIAL_VERIFICATION: elif self.new_status == RequestStatus.PENDING_FINANCIAL_VERIFICATION:
return "Accepted" return "Accepted"
else: else:

View File

@ -35,7 +35,7 @@ def render_approval(request, form=None):
return render_template( return render_template(
"requests/approval.html", "requests/approval.html",
data=data, data=data,
status_events=reversed(request.status_events), reviews=list(reversed(request.reviews)),
request=request, request=request,
current_status=request.status.value, current_status=request.status.value,
pending_review=pending_review, pending_review=pending_review,
@ -58,7 +58,7 @@ def submit_approval(request_id):
form = CCPOReviewForm(http_request.form) form = CCPOReviewForm(http_request.form)
if form.validate(): if form.validate():
if http_request.form.get("approved"): if http_request.form.get("review") == "approving":
Requests.advance(g.current_user, request, form.data) Requests.advance(g.current_user, request, form.data)
else: else:
Requests.request_changes(g.current_user, request, form.data) Requests.request_changes(g.current_user, request, form.data)
@ -93,4 +93,6 @@ def create_internal_comment(request_id):
request = Requests.get(g.current_user, request_id) request = Requests.get(g.current_user, request_id)
Requests.update_internal_comments(g.current_user, request, form.data["text"]) Requests.update_internal_comments(g.current_user, request, form.data["text"])
return redirect(url_for("requests.approval", request_id=request_id)) return redirect(
url_for("requests.approval", request_id=request_id, _anchor="ccpo-notes")
)

View File

@ -0,0 +1,28 @@
import textinput from '../text_input'
export default {
name: 'ccpo-approval',
components: {
textinput
},
data: function () {
return {
approving: false,
denying: false
}
},
methods: {
setReview: function (e) {
if (e.target.value === 'approving') {
this.approving = true
this.denying = false
} else {
this.approving = false
this.denying = true
}
},
}
}

View File

@ -0,0 +1,21 @@
import { format } from 'date-fns'
export default {
name: 'local-datetime',
props: {
timestamp: String,
format: {
type: String,
default: 'MMM D YYYY H:mm'
}
},
computed: {
displayTime: function () {
return format(this.timestamp, this.format)
}
},
template: '<time v-bind:datetime="timestamp">{{ this.displayTime }}</time>'
}

View File

@ -19,7 +19,8 @@ export default {
default: () => '' default: () => ''
}, },
initialErrors: Array, initialErrors: Array,
paragraph: String paragraph: String,
noMaxWidth: String
}, },
data: function () { data: function () {

View File

@ -16,6 +16,8 @@ import NewProject from './components/forms/new_project'
import Modal from './mixins/modal' import Modal from './mixins/modal'
import selector from './components/selector' import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart' import BudgetChart from './components/charts/budget_chart'
import CcpoApproval from './components/forms/ccpo_approval'
import LocalDatetime from './components/local_datetime'
Vue.use(VTooltip) Vue.use(VTooltip)
@ -33,7 +35,9 @@ const app = new Vue({
financial, financial,
NewProject, NewProject,
selector, selector,
BudgetChart BudgetChart,
CcpoApproval,
LocalDatetime
}, },
mounted: function() { mounted: function() {
const modalOpen = document.querySelector("#modalOpen") const modalOpen = document.querySelector("#modalOpen")

View File

@ -9,6 +9,10 @@
border-left-width: $gap / 2; border-left-width: $gap / 2;
border-left-style: solid; border-left-style: solid;
@include panel-margin; @include panel-margin;
@include media($medium-screen) {
padding: $gap * 4;
}
} }
@mixin alert-level($level) { @mixin alert-level($level) {
@ -53,6 +57,10 @@
.alert__title { .alert__title {
@include h3; @include h3;
margin-top: 0; margin-top: 0;
&:last-child {
margin-bottom: 0;
}
} }
.alert__content { .alert__content {

View File

@ -5,6 +5,10 @@
margin-top: $gap * 4; margin-top: $gap * 4;
margin-bottom: $gap * 4; margin-bottom: $gap * 4;
&--tight {
margin-top: $gap * 2;
}
.usa-button, .usa-button,
a { a {
margin: 0 0 0 ($gap * 2); margin: 0 0 0 ($gap * 2);

View File

@ -246,6 +246,19 @@
} }
} }
&.no-max-width {
padding-right: $gap * 3;
input, textarea, select, label {
max-width: none;
}
.icon-validation {
left: auto;
right: - $gap * 4;
}
}
&.usa-input--error { &.usa-input--error {
@include input-state('error'); @include input-state('error');
} }

View File

@ -15,6 +15,7 @@
margin: 0 $gap; margin: 0 $gap;
padding: 0 $gap; padding: 0 $gap;
border-radius: $gap / 2; border-radius: $gap / 2;
white-space: nowrap;
&.label--info { &.label--info {
background-color: $color-primary; background-color: $color-primary;

View File

@ -63,6 +63,7 @@
.panel__heading { .panel__heading {
padding: $gap * 2; padding: $gap * 2;
@include media($medium-screen) { @include media($medium-screen) {
padding: $gap * 4; padding: $gap * 4;
} }
@ -71,6 +72,10 @@
padding: $gap*2; padding: $gap*2;
} }
&--divider {
border-bottom: 1px solid $color-gray-light;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 0; margin: 0;
display: inline-block; display: inline-block;
@ -90,15 +95,16 @@
justify-content: space-between; justify-content: space-between;
} }
} }
hr {
border: 0;
border-bottom: 1px dashed $color-gray-light;
margin: ($gap * 4) ($site-margins*-4);
}
} }
.panel__actions { .panel__actions {
@include panel-actions; @include panel-actions;
} }
hr {
border: 0;
border-bottom: 1px dashed $color-gray-light;
margin: 0px $site-margins*-4;
}

View File

@ -32,6 +32,12 @@
} }
} }
.request-approval__review {
.action-group {
margin-bottom: $gap * 6;
}
}
.approval-log { .approval-log {
ol { ol {
list-style: none; list-style: none;
@ -43,7 +49,7 @@
border-top: 1px dashed $color-gray-light; border-top: 1px dashed $color-gray-light;
&:first-child { &:first-child {
border-top-style: solid; border-top: none;
} }
@include media($medium-screen) { @include media($medium-screen) {
@ -94,4 +100,10 @@
} }
} }
} }
.internal-notes {
textarea {
resize: vertical;
}
}
} }

View File

@ -1,24 +1,35 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/tooltip.html" import Tooltip %} {% from "components/tooltip.html" import Tooltip %}
{% macro TextInput(field, tooltip='', placeholder='', validation='anything', paragraph=False, initial_value='') -%} {% macro TextInput(
field,
label=field.label | striptags,
description=field.description,
tooltip='',
placeholder='',
validation='anything',
paragraph=False,
initial_value='',
noMaxWidth=False) -%}
<textinput <textinput
name='{{ field.name }}' name='{{ field.name }}'
validation='{{ validation }}' validation='{{ validation }}'
{% if paragraph %}paragraph='true'{% endif %} {% if paragraph %}paragraph='true'{% endif %}
{% if noMaxWidth %}no-max-width='true'{% endif %}
{% if initial_value or field.data %}initial-value='{{ initial_value or field.data }}'{% endif %} {% if initial_value or field.data %}initial-value='{{ initial_value or field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %} {% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %}
key='{{ field.name }}' key='{{ field.name }}'
inline-template> inline-template>
<div <div
v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph }]"> v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
<label for={{field.name}}> <label for={{field.name}}>
<div class="usa-input__title">{{ field.label | striptags }} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}</div> <div class="usa-input__title">{{ label }} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}</div>
{% if field.description %} {% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span> <span class='usa-input__help'>{{ description | safe }}</span>
{% endif %} {% endif %}
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span> <span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>

View File

@ -23,8 +23,6 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<hr>
<h2> <h2>
Details of Use Details of Use
{% if editable %} {% if editable %}

View File

@ -16,8 +16,9 @@
{% endif %} {% endif %}
<section class='panel'> <section class='panel'>
<header class='panel__heading request-approval__heading'> <header class='panel__heading panel__heading--divider request-approval__heading'>
<h1 class='h2'>Request #{{ request.id }}</h1> <h1 class='h2'>Request #{{ request.id }}
</h1>
<span class='label label--info'>{{ current_status }}</span> <span class='label label--info'>{{ current_status }}</span>
</header> </header>
@ -32,153 +33,180 @@
</section> </section>
{% if pending_review %} {% if pending_review %}
<form method="POST" action="{{ url_for("requests.submit_approval", request_id=request.id) }}" autocomplete="off"> <section class='request-approval__review'>
{{ f.csrf_token }} <form method="POST" action="{{ url_for("requests.submit_approval", request_id=request.id) }}" autocomplete="off">
<section class='panel'> {{ f.csrf_token }}
<header class='panel__heading'>
<h2 class='h3'>Approval Notes</h2>
</header>
<div class='panel__content'> <ccpo-approval inline-template>
<div>
<div class='panel'>
<div class='panel__content'>
<h2>Review this Request</h2>
<div class="form__sub-fields"> <div class='usa-input'>
<fieldset class='usa-input__choices usa-input__choices--inline'>
<input v-on:change='setReview' type='radio' name='review' id='review-approving' value='approving'/>
<label for='review-approving'>Ready for approval</label>
<h3>Instructions for the Requestor</h3> <input v-on:change='setReview' type='radio' name='review' id='review-denying' value='denying'/>
<label for='review-denying'>Request revisions</label>
</fieldset>
</div>
Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <b>These notes will be visible to the person making the JEDI Cloud request</b>. <div v-if='approving || denying' class='form__sub-fields' v-cloak>
<h3>Message to Requestor <span class='subtitle'>(optional)</span></h3>
<div v-if='approving' key='approving' v-cloak>
{{ TextInput(
f.comment,
label='Approval comments or notes',
description='Provide any comments or notes regarding the approval of this request. <strong>This message will be shared with the person making the JEDI request.</strong>.',
paragraph=True,
noMaxWidth=True
) }}
</div>
{{ TextInput(f.comment, paragraph=True, placeholder="Add notes or comments explaining what changes are being requested or why further discussion is needed about this request.") }} <div v-else key='denying' v-cloak>
{{ TextInput(
f.comment,
label='Revision instructions or notes',
paragraph=True,
noMaxWidth=True
) }}
</div>
</div>
</div> <div v-if='approving' class='form__sub-fields' v-cloak>
<div class="form-row"> <h3>Authorizing Officials <span class='subtitle'>(optional)</span></h3>
<div class="form-col"> <p>Provide the name of the key officials for both parties that have authorized this request to move forward. <strong>This section is not visible to the person making the request. It is only viewable by CCPO staff.</strong></p>
<h3>Authorizing Officials</h3>
<p>This section is not visible to the person making the request. It is only viewable by CCPO staff.</p>
<p>Provide the name of the key officials for both parties that have authorized this request to move forward.</p>
<hr />
<h4>Mission Authorizing Official</h4>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.fname_mao, placeholder="First name of mission authorizing official") }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.lname_mao, placeholder="Last name of mission authorizing official") }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.email_mao, placeholder="name@mail.mil", validation='email') }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.phone_mao, placeholder="(123) 456-7890", validation='usPhone') }}
</div>
</div>
<hr />
<h4>CCPO Authorizing Official</h4>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.fname_ccpo, placeholder="First name of CCPO authorizing official") }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.lname_ccpo, placeholder="Last name of CCPO authorizing official") }}
</div>
</div>
</div>
</div>
</div>
<div v-if='approving || denying' class='action-group' v-cloak>
<button v-if='approving' type="submit" name="approved" class='usa-button usa-button-big'>Approve Request</button>
<button v-if='denying' type="submit" name="denied" class='usa-button usa-button-big'>Request Revisions</button>
<a href='{{ url_for("requests.requests_index") }}' class='icon-link'>
{{ Icon('x') }}
<span>Cancel</span>
</a>
</div> </div>
</div> </div>
<h4 class="h3">Mission Authorizing Official</h4> </ccpo-approval>
</form>
</section>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.fname_mao, placeholder="First name of mission authorizing official") }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.lname_mao, placeholder="Last name of mission authorizing official") }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.email_mao, placeholder="name@mail.mil") }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.phone_mao, placeholder="(123) 456-7890", validation='usPhone') }}
</div>
</div>
<h4 class="h3">CCPO Authorizing Official</h4>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(f.fname_ccpo, placeholder="First name of CCPO authorizing official") }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(f.lname_ccpo, placeholder="Last name of CCPO authorizing official") }}
</div>
</div>
</section>
<section class='action-group'>
<input type="submit" name="approved" class='usa-button usa-button-big' value='Approve Request'>
<input type="submit" name="denied" class='usa-button usa-button-big usa-button-secondary' value='Mark as Changes Requested'>
<a href='{{ url_for("requests.requests_index") }}' class='icon-link'>
{{ Icon('x') }}
<span>Cancel</span>
</a>
</section>
</form>
{% endif %} {% endif %}
<section class='panel'>
<h4 class="h3">CCPO Internal Notes</h4> <section class='internal-notes' id='ccpo-notes'>
<p>You may add additional comments and notes for internal CCPO reference and follow-up here.</p> <form method="POST" action="{{ url_for('requests.create_internal_comment', request_id=request.id) }}">
<div class='form-row'> <div class='panel'>
<div class='form-col'> <div class='panel__content'>
<form method="POST" action="{{ url_for('requests.create_internal_comment', request_id=request.id) }}">
{{ internal_comment_form.csrf_token }} {{ internal_comment_form.csrf_token }}
{{ TextInput(internal_comment_form.text, paragraph=True) }} {{ TextInput(internal_comment_form.text, paragraph=True, noMaxWidth=True) }}
<button type="submit">Leave comment</button>
</form> </div>
</div> </div>
</div>
</div> <div class='action-group action-group--tight'>
<button class='usa-button' type="submit">Save Notes</button>
</div>
</form>
</section> </section>
<section class='panel'> <section>
<header class='panel__heading'> <div class='panel'>
<h2 class='h3 request-approval__columns__heading'>Approval Log</h2> <header class='panel__heading panel__heading--divider'>
</header> <h2 class='h3 request-approval__columns__heading'>CCPO Activity Log</h2>
<div> </header>
<div class='approval-log'>
<ol>
{% for status_event in status_events %} <div class='approval-log'>
{% if status_event.review %} {% if reviews %}
<li> <ol>
<article class='approval-log__log-item'> {% for review in reviews %}
<div> <li>
<h3 class='approval-log__log-item__header'>{{ status_event.log_name }} by {{ status_event.review.full_name_reviewer }}</h3> <article class='approval-log__log-item'>
{% if status_event.review.comment %} <div>
<p>{{ status_event.review.comment }}</p> {{ review.log_name }}
<h3 class='approval-log__log-item__header'>{{ review.status.log_name }} by {{ review.full_name_reviewer }}</h3>
{% if review.comment %}
<p>{{ review.comment }}</p>
{% endif %}
<div class='approval-log__behalfs'>
{% if review.lname_mao %}
<div class='approval-log__behalf'>
<h3 class='approval-log__log-item__header'>Mission Owner approval on behalf of:</h3>
<span>{{ review.full_name_mao }}</span>
<span>{{ review.email_mao }}</span>
<span>{{ review.phone_mao }}</span>
</div>
{% endif %} {% endif %}
<div class='approval-log__behalfs'> {% if review.lname_ccpo %}
{% if status_event.review.lname_mao %} <div class='approval-log__behalf'>
<div class='approval-log__behalf'> <h3 class='approval-log__log-item__header'>CCPO approval on behalf of:</h3>
<h3 class='approval-log__log-item__header'>Mission Owner approval on behalf of:</h3> <span>{{ review.full_name_ccpo }}</span>
<span>{{ status_event.review.full_name_mao }}</span> </div>
<span>{{ status_event.review.email_mao }}</span> {% endif %}
<span>{{ status_event.review.phone_mao }}</span>
</div>
{% endif %}
{% if status_event.review.lname_ccpo %}
<div class='approval-log__behalf'>
<h3 class='approval-log__log-item__header'>CCPO approval on behalf of:</h3>
<span>{{ status_event.review.full_name_ccpo }}</span>
</div>
{% endif %}
</div>
</div> </div>
{% set timestamp=status_event.time_created | formattedDate("%Y-%m-%d %H:%M:%S %Z") %} </div>
<footer class='approval-log__log-item__timestamp'><time datetime='{{ timestamp }}'>{{ timestamp }}</time></footer> {% set timestamp=review.status.time_created | formattedDate("%Y-%m-%d %H:%M:%S %Z") %}
</article> <footer class='approval-log__log-item__timestamp'>
</li> <local-datetime timestamp='{{ timestamp }}'></local-datetime>
{% endif %} </footer>
</article>
</li>
{% endfor %} {% endfor %}
</ol> </ol>
{% else %}
<div class='panel__content'>
<p class='h4'>No CCPO approvals or request changes have been recorded yet.</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</section> </section>
</article> </article>
{% endblock %} {% endblock %}

View File

@ -87,7 +87,7 @@ def test_can_submit_request_approval(client, user_session):
status=RequestStatus.PENDING_CCPO_ACCEPTANCE status=RequestStatus.PENDING_CCPO_ACCEPTANCE
) )
review_data = RequestReviewFactory.dictionary() review_data = RequestReviewFactory.dictionary()
review_data["approved"] = True review_data["review"] = "approving"
response = client.post( response = client.post(
url_for("requests.submit_approval", request_id=request.id), data=review_data url_for("requests.submit_approval", request_id=request.id), data=review_data
) )
@ -102,7 +102,7 @@ def test_can_submit_request_denial(client, user_session):
status=RequestStatus.PENDING_CCPO_ACCEPTANCE status=RequestStatus.PENDING_CCPO_ACCEPTANCE
) )
review_data = RequestReviewFactory.dictionary() review_data = RequestReviewFactory.dictionary()
review_data["denied"] = True review_data["review"] = "denying"
response = client.post( response = client.post(
url_for("requests.submit_approval", request_id=request.id), data=review_data url_for("requests.submit_approval", request_id=request.id), data=review_data
) )