Merge pull request #751 from dod-ccpo/view-app-envs

View Application Environments Table
This commit is contained in:
montana-mil 2019-04-11 17:10:43 -04:00 committed by GitHub
commit bf4eb23557
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 395 additions and 251 deletions

View File

@ -64,11 +64,16 @@ def edit_application(portfolio_id, application_id):
application = Applications.get(application_id)
form = ApplicationForm(name=application.name, description=application.description)
environments_obj = {}
for env in application.environments:
environments_obj[env.name] = [user.full_name for user in env.users]
return render_template(
"portfolios/applications/edit.html",
portfolio=portfolio,
application=application,
form=form,
environments_obj=environments_obj,
)

View File

@ -0,0 +1,34 @@
import { set } from 'vue/dist/vue'
export default {
name: 'environments-table',
props: {
environments: Object,
},
data: function() {
return {
environmentsState: this.environments,
}
},
created: function() {
Object.keys(this.environments).forEach(environment => {
set(this.environmentsState[environment], 'isVisible', false)
})
},
methods: {
toggle: function(e, environmentName) {
this.environmentsState = Object.assign(this.environmentsState, {
[environmentName]: Object.assign(
this.environmentsState[environmentName],
{
isVisible: !this.environmentsState[environmentName].isVisible,
}
),
})
},
},
}

View File

@ -25,6 +25,7 @@ import Modal from './mixins/modal'
import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart'
import SpendTable from './components/tables/spend_table'
import EnvironmentsTable from './components/tables/application_environments'
import TaskOrderList from './components/tables/task_order_list.js'
import MembersList from './components/members_list'
import LocalDatetime from './components/local_datetime'
@ -56,6 +57,7 @@ const app = new Vue({
selector,
BudgetChart,
SpendTable,
EnvironmentsTable,
TaskOrderList,
MembersList,
LocalDatetime,

View File

@ -12,7 +12,7 @@
@import 'elements/buttons';
@import 'elements/panels';
@import 'elements/block_lists';
@import 'elements/accordians';
@import 'elements/accordions';
@import 'elements/tables';
@import 'elements/sidenav';
@import 'elements/action_group';
@ -23,6 +23,7 @@
@import 'elements/graphs';
@import 'elements/menu';
@import 'components/accordion_table';
@import 'components/topbar';
@import 'components/top_message';
@import 'components/global_layout';

View File

@ -0,0 +1,66 @@
.accordion-table {
table {
thead th {
text-transform: uppercase;
border-bottom: 1px solid $color-gray-lightest;
border-top: none;
}
th, td {
white-space: nowrap;
button {
margin: 0;
}
}
.accordion-table__items {
.accordion-table__item__toggler {
@include icon-link-color($color-blue, $color-gray-lightest);
float: right;
margin-left: -$gap;
color: $color-blue;
.icon {
@include icon-size(12);
margin-right: $gap;
}
.open-indicator {
position: absolute;
bottom: 0;
left: 5 * $gap;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid $color-blue-light;
}
}
th, td, tr {
border-bottom: 1px dashed $color-gray-lightest;
}
th[scope=rowgroup] {
position: relative;
}
.accordion-table__item__expanded {
margin-left: 2 * $gap;
th, td {
.icon-link {
font-weight: $font-normal;
font-size: $base-font-size;
}
border-bottom: 1px dashed $color-white;
background-color: $color-gray-lightest;
}
}
}
}
}

View File

@ -171,26 +171,6 @@
padding-bottom: 0;
}
.member-list-header {
margin: 2 * $gap 5 * $gap;
padding: inherit;
overflow: auto;
.left {
float: left;
padding-bottom: 0.8rem;
}
.icon-link {
float: right;
margin-top: 0.8rem;
}
.icon {
}
}
.subheading {
font-size: 1.4rem;
color: $color-gray;
@ -326,15 +306,6 @@
}
}
.members-table-footer {
float: right;
padding: 3 * $gap 0;
.action-group.save {
padding-right: 3 * $gap;
}
}
a.modal-link.icon-link {
float: right;

View File

@ -1,4 +1,4 @@
.accordian {
.accordion {
@include block-list;
box-shadow: 0 4px 10px 0 rgba(193,193,193,0.5);
@ -21,7 +21,7 @@
}
}
.accordian__header {
.accordion__header {
@include block-list-header;
border-top: 3px solid $color-blue;
border-bottom: none;
@ -32,7 +32,7 @@
}
}
.accordian__title {
.accordion__title {
@include block-list__title;
color: $color-blue;
@include h3;
@ -45,14 +45,14 @@
}
}
.accordian__description {
.accordion__description {
@include block-list__description;
font-style: italic;
font-size: $small-font-size;
color: $color-gray;
}
.accordian__actions {
.accordion__actions {
margin-top: $gap;
display: flex;
flex-direction: row;
@ -76,14 +76,14 @@
}
}
.accordian__item {
.accordion__item {
@include block-list-item;
opacity: 0.75;
background-color: $color-blue-light;
border-bottom: 1px solid rgba($color-gray-light, 0.5);
&.accordian__item--selectable {
&.accordion__item--selectable {
> div {
display: flex;
flex-direction: row-reverse;
@ -117,7 +117,7 @@
}
}
.accordian__footer {
.accordion__footer {
@include block-list__footer;
border-top: 0;
}

View File

@ -107,6 +107,10 @@
}
}
.panel__footer {
padding: 3 * $gap;
}
hr {
border: 0;
border-bottom: 1px dashed $color-gray-light;

View File

@ -102,6 +102,25 @@
overflow-x: auto;
@include panel-margin;
.responsive-table-wrapper__header {
@include panel-base;
@include panel-theme-default;
border-top: none;
border-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding: $gap * 2;
.responsive-table-wrapper__title {
@include h4;
font-size: $lead-font-size;
flex: 2;
}
}
table {
margin-bottom: 0;
}

View File

@ -24,7 +24,41 @@
}
}
header.accordian__header {
padding: 1.6rem;
.list-header {
margin: 2 * $gap 5 * $gap;
padding: inherit;
overflow: auto;
}
.icon-link {
.icon--info {
bottom: -1px;
left: 4px;
}
}
table {
thead {
td {
font-weight: bold;
font-size: 1.4rem;
border-top: 0;
}
}
tbody {
th {
font-weight: bold;
font-size: 1.6rem;
}
td {
font-size: 1.6rem;
border-bottom: 1px solid $color-gray-lightest;
border-top: 0;
padding: 3 * $gap 2 * $gap;
}
}
}
}

View File

@ -201,48 +201,20 @@
}
}
.spend-table {
box-shadow: 0 6px 18px 0 rgba(144,164,183,0.3);
.spend-table__header {
@include panel-base;
@include panel-theme-default;
border-top: none;
border-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding: $gap * 2;
.spend-table__title {
@include h4;
font-size: $lead-font-size;
flex: 2;
}
.spend-table__month-select {
margin: 0;
flex: 1;
}
}
table {
thead th {
text-transform: uppercase;
.spend-table__portfolio {
th, td {
font-weight: bold;
border-bottom: 1px solid $color-gray-lightest;
border-top: none;
}
}
th, td {
white-space: nowrap;
button {
margin: 0;
}
&.previous-month {
color: $color-gray;
}
@ -287,59 +259,4 @@
}
}
}
.spend-table__portfolio {
th, td {
font-weight: bold;
border-bottom: 1px solid $color-gray-lightest;
}
}
.spend-table__application {
.spend-table__application__toggler {
@include icon-link-color($color-blue, $color-gray-lightest);
margin-left: -$gap;
color: $color-blue;
.icon {
@include icon-size(12);
margin-right: $gap;
}
.open-indicator {
position: absolute;
bottom: 0;
left: 5 * $gap;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid $color-blue-light;
}
}
th, td {
border-bottom: none;
}
th[scope=rowgroup] {
position: relative;
}
.spend-table__application__env {
margin-left: 2 * $gap;
th, td {
.icon-link {
font-weight: $font-normal;
font-size: $base-font-size;
}
border-bottom: 1px dashed $color-white;
background-color: $color-blue-light;
}
}
}
}
}

View File

@ -6,7 +6,7 @@
{% from "components/alert.html" import Alert %}
<section class="member-list" id="portfolio-members">
<div class='responsive-table-wrapper panel'>
<div class='responsive-table-wrapper panel accordion-table'>
{% if g.matchesPath("portfolio-members") %}
{% include "fragments/flash.html" %}
{% endif %}
@ -14,18 +14,22 @@
<form method='POST' id="member-perms" action='{{ url_for("portfolios.edit_portfolio_members", portfolio_id=portfolio.id) }}' autocomplete="off" enctype="multipart/form-data">
{{ member_perms_form.csrf_token }}
<div class='member-list-header'>
<div class='left'>
<div class='application-list-item'>
<header>
<div class='responsive-table-wrapper__header'>
<div class='responsive-table-wrapper__title'>
<div class='h3'>{{ "portfolios.admin.portfolio_members_title" | translate }}</div>
<div class='subheading'>
{{ "portfolios.admin.portfolio_members_subheading" | translate }}
</div>
</div>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
{% if not portfolio.members %}
<p>{{ "portfolios.admin.no_members" | translate }}</p>
@ -52,15 +56,20 @@
</tbody>
</table>
<div class="members-table-footer">
</div>
<div class="panel__footer">
<div class="action-group save">
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) %}
{{ SaveButton(text=('common.save' | translate), element="input", form="member-perms") }}
{% endif %}
</div>
</div>
{% endif %}
{% if user_can(permissions.CREATE_PORTFOLIO_USERS) %}
{% include "fragments/admin/add_new_portfolio_member.html" %}
{% endif %}
</div>
</div>
{% endif %}
</form>
</base-form>
@ -78,6 +87,7 @@
)
}}
<div class="panel__footer">
<div class="action-group">
<form method="POST" action="{{ url_for('portfolios.remove_member', portfolio_id=portfolio.id, user_id=member.user_id) }}">
{{ member_perms_form.csrf_token }}
@ -87,17 +97,11 @@
</form>
<a v-on:click="closeModal('{{ modal_id }}')" class="action-group__action icon-link icon-link--default">{{ "common.cancel" | translate }}</a>
</div>
</div>
{% endcall %}
{% endfor %}
{% endif %}
<div class="members-table-footer">
<div class="action-group">
{% if user_can(permissions.CREATE_PORTFOLIO_USERS) %}
{% include "fragments/admin/add_new_portfolio_member.html" %}
{% endif %}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,50 @@
{% from "components/icon.html" import Icon %}
<div class="application-list-item">
<header>
<div class="responsive-table-wrapper__header">
<div class='responsive-table-wrapper__title'>
<div class='h3'>{{ 'portfolios.applications.environments_heading' | translate }}</div>
</div>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
<environments-table
v-cloak
v-bind:environments='{{ environments_obj }}'
inline-template>
<table>
<thead>
<th scope='col'>{{ "portfolios.applications.environments.name" | translate }}</th>
<th scope='col' class='table-cell--align-right'>{{ "portfolios.applications.environments.members" | translate }}</th>
</thead>
<tbody v-for='(members_list, name) in environments' class='accordion-table__items'>
<tr>
<th scope='rowgroup' v-on:click="toggle($event, name)" v-html='name'></th>
<template v-if="environmentsState[name].isVisible">
<td v-on:click="toggle($event, name)" class='icon-link icon-link--large accordion-table__item__toggler'>Hide Members (<span v-html='members_list.length'></span>){{ Icon('caret_up') }}</td>
</template>
<template v-else>
<td v-on:click="toggle($event, name)" class='icon-link icon-link--large accordion-table__item__toggler'>Show Members (<span v-html='members_list.length'></span>){{ Icon('caret_down') }}</td>
</template>
</tr>
<tr scope='rowgroup' v-for='member in members_list' v-show='environmentsState[name].isVisible' class='accordion-table__item__expanded'>
<td>
<div>
<span v-html='member'></span>
</div>
</td>
<td class='table-cell--expand'></td>
</tr>
</tbody>
</table>
</environments-table>
</div>

View File

@ -1,6 +1,7 @@
{% extends "portfolios/applications/base.html" %}
{% from "components/text_input.html" import TextInput %}
{% from "components/icon.html" import Icon %}
{% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %}
@ -12,33 +13,28 @@
<div class="panel">
<div class="panel__content">
{% include "fragments/edit_application_form.html" %}
<div class="application-list-item">
<header>
<h2 class="block-list__title">{{ 'portfolios.applications.environments_heading' | translate }}</h2>
<p>
{{ 'portfolios.applications.environments_description' | translate }}
</p>
</header>
<ul>
{% for environment in application.environments %}
<li class="application-edit__env-list-item">
<div class="usa-input input--disabled">
<label>Environment Name</label>
<input type="text" disabled value="{{ environment.name }}" readonly />
</div>
</li>
{% endfor %}
</ul>
</div>
{% include "fragments/applications/edit_application_form.html" %}
</div>
</div>
<div class="panel__footer">
<div class="action-group">
<button class="usa-button usa-button-primary" tabindex="0" type="submit">{{ 'portfolios.applications.update_button_text' | translate }}</button>
</div>
</div>
</div>
</form>
<div class="accordion-table responsive-table-wrapper panel">
{% include "fragments/applications/environments.html" %}
<div class="panel__footer">
<div class="action-group">
<button class="usa-button usa-button-primary" tabindex="0" type="submit">{{ 'portfolios.applications.update_button_text' | translate }}</button>
<a class='icon-link'>
{{ "portfolios.applications.add_environment" | translate }}
{{ Icon('plus-circle-solid') }}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -34,13 +34,13 @@
<div class='application-list'>
{% for application in portfolio.applications|sort(attribute='name') %}
<div is='toggler' v-cloak class='accordian application-list-item'>
<div is='toggler' v-cloak class='accordion application-list-item'>
<template slot-scope='props'>
<header class='accordian__header row'>
<header class='accordion__header row'>
<div class='col col-grow'>
<h3 class='icon-link accordian__title' v-on:click="props.toggle">{{ application.name }}</h3>
<span class='accordian__description'>{{ application.description }}</span>
<div class='accordian__actions'>
<h3 class='icon-link accordion__title' v-on:click="props.toggle">{{ application.name }}</h3>
<span class='accordion__description'>{{ application.description }}</span>
<div class='accordion__actions'>
{% if user_can(permissions.EDIT_APPLICATION) %}
<a class='icon-link' href='{{ url_for("portfolios.edit_application", portfolio_id=portfolio.id, application_id=application.id) }}'>
<span>{{ "portfolios.applications.app_settings_text" | translate }}</span>
@ -68,7 +68,7 @@
</header>
<ul v-if="props.isVisible">
{% for environment in application.environments %}
<li class='accordian__item application-list-item__environment'>
<li class='accordion__item application-list-item__environment'>
<div class='application-list-item__environment__name'>
<span>{{ environment.name }}</span>
</div>

View File

@ -37,7 +37,7 @@
</div>
{% endcall %}
{% include "fragments/edit_application_form.html" %}
{% include "fragments/applications/edit_application_form.html" %}
<div> {# this extra div prevents this bug: https://www.pivotaltracker.com/story/show/160768940 #}
<div v-cloak v-for="title in errors" :key="title">

View File

@ -346,9 +346,9 @@
</div>
</budget-chart>
<div class='spend-table responsive-table-wrapper'>
<div class='spend-table__header'>
<h2 class='spend-table__title'>Total spent per month</h2>
<div class='accordion-table responsive-table-wrapper'>
<div class='responsive-table-wrapper__header'>
<h2 class='responsive-table-wrapper__title'>Total spent per month</h2>
<select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'>
{% for m in cumulative_budget["months"] %}
@ -402,10 +402,10 @@
</tr>
</tbody>
<tbody v-for='(application, name) in applicationsState' class='spend-table__application'>
<tbody v-for='(application, name) in applicationsState' class='accordion-table__items'>
<tr>
<th scope='rowgroup'>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large spend-table__application__toggler'>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large accordion-table__item__toggler'>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}<div class='open-indicator'></div></template>
<template v-else>{{ Icon('caret_right') }}</template>
<span v-html='name'></span>
@ -433,9 +433,9 @@
</td>
</tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='spend-table__application__env'>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='accordion-table__item__expanded'>
<th scope='rowgroup'>
<div class='icon-link spend-table__application__env'>
<div class='icon-link accordion-table__item__expanded'>
<span v-html='envName'></span>
</div>
</th>

View File

@ -13,6 +13,8 @@ from atst.domain.applications import Applications
from atst.domain.portfolios import Portfolios
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from tests.utils import captured_templates
def test_user_with_permission_has_budget_report_link(client, user_session):
portfolio = PortfolioFactory.create()
@ -105,6 +107,41 @@ def test_view_edit_application(client, user_session):
assert response.status_code == 200
def test_edit_application_environments_obj(app, client, user_session):
portfolio = PortfolioFactory.create()
application = Applications.create(
portfolio,
"Snazzy Application",
"A new application for me and my friends",
{"env1", "env2"},
)
user1 = UserFactory.create()
user2 = UserFactory.create()
env1 = application.environments[0]
env2 = application.environments[1]
EnvironmentRoleFactory.create(environment=env1, user=user1)
EnvironmentRoleFactory.create(environment=env1, user=user2)
EnvironmentRoleFactory.create(environment=env2, user=user1)
user_session(portfolio.owner)
with captured_templates(app) as templates:
response = app.test_client().get(
url_for(
"portfolios.edit_application",
portfolio_id=portfolio.id,
application_id=application.id,
)
)
assert response.status_code == 200
_, context = templates[0]
assert context["environments_obj"] == {
env1.name: [user1.full_name, user2.full_name],
env2.name: [user1.full_name],
}
def test_user_with_permission_can_update_application(client, user_session):
owner = UserFactory.create()
portfolio = PortfolioFactory.create(

View File

@ -580,13 +580,17 @@ portfolios:
existing_application_title: '{application_name} Application Settings'
new_application_title: New Application
settings_heading: Application Settings
environments_heading: Environments
environments_heading: Application Environments
environments_description: Each environment created within an application is logically separated from one another for easier management and security.
update_button_text: Save
create_button_text: Create
team_management:
title: '{application_name} Team Management'
subheading: Team Management
environments:
name: Name
members: Members
add_environment: Add New Environment
admin:
portfolio_members_title: Portfolio members
portfolio_members_subheading: These members have different levels of access to the portfolio.