diff --git a/atst/app.py b/atst/app.py index 4413a55f..adf018ed 100644 --- a/atst/app.py +++ b/atst/app.py @@ -84,7 +84,7 @@ def make_flask_callbacks(app): def _set_globals(): g.current_user = None g.dev = os.getenv("FLASK_ENV", "dev") == "dev" - g.matchesPath = lambda href: re.match("^" + href, request.path) + g.matchesPath = lambda href: re.search(href, request.full_path) g.modal = request.args.get("modal", None) g.Authorization = Authorization g.Permissions = Permissions diff --git a/atst/forms/portfolio_member.py b/atst/forms/portfolio_member.py index 492777a7..2293deed 100644 --- a/atst/forms/portfolio_member.py +++ b/atst/forms/portfolio_member.py @@ -1,10 +1,10 @@ from wtforms.fields import StringField, FormField, FieldList -from wtforms.fields.html5 import EmailField -from wtforms.validators import Required, Email, Length +from wtforms.fields.html5 import EmailField, TelField +from wtforms.validators import Required, Email, Length, Optional from atst.domain.permission_sets import PermissionSets from .forms import BaseForm -from atst.forms.validators import IsNumber +from atst.forms.validators import IsNumber, PhoneNumber from atst.forms.fields import SelectField from atst.utils.localization import translate @@ -71,6 +71,10 @@ class NewForm(PermissionsForm): email = EmailField( translate("forms.new_member.email_label"), validators=[Required(), Email()] ) + phone_number = TelField( + translate("forms.new_member.phone_number_label"), + validators=[Optional(), PhoneNumber()], + ) dod_id = StringField( translate("forms.new_member.dod_id_label"), validators=[Required(), Length(min=10), IsNumber()], diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index b87763d4..78f0ba68 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -8,7 +8,7 @@ from atst.domain.portfolios import Portfolios from atst.domain.audit_log import AuditLog from atst.domain.common import Paginator from atst.forms.portfolio import PortfolioForm -from atst.forms.portfolio_member import MembersPermissionsForm +import atst.forms.portfolio_member as member_forms from atst.models.permissions import Permissions from atst.domain.permission_sets import PermissionSets from atst.domain.authz.decorator import user_can_access_decorator as user_can @@ -63,7 +63,7 @@ def render_admin_page(portfolio, form=None): members_data = [serialize_member_form_data(member) for member in portfolio.members] portfolio_form = PortfolioForm(data={"name": portfolio.name}) - member_perms_form = MembersPermissionsForm( + member_perms_form = member_forms.MembersPermissionsForm( data={"members_permissions": members_data} ) return render_template( @@ -71,6 +71,7 @@ def render_admin_page(portfolio, form=None): form=form, portfolio_form=portfolio_form, member_perms_form=member_perms_form, + member_form=member_forms.NewForm(), portfolio=portfolio, audit_events=audit_events, user=g.current_user, diff --git a/atst/routes/portfolios/members.py b/atst/routes/portfolios/members.py index 46aaf8fb..1db59cfb 100644 --- a/atst/routes/portfolios/members.py +++ b/atst/routes/portfolios/members.py @@ -92,7 +92,12 @@ def create_member(portfolio_id): flash("new_portfolio_member", new_member=member, portfolio=portfolio) return redirect( - url_for("portfolios.portfolio_members", portfolio_id=portfolio.id) + url_for( + "portfolios.portfolio_admin", + portfolio_id=portfolio.id, + fragment="portfolio-members", + _anchor="portfolio-members", + ) ) except AlreadyExistsError: return render_template( diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 7b1db8c2..69239977 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -22,10 +22,9 @@ MESSAGES = { "category": "success", }, "new_portfolio_member": { - "title_template": "Member added successfully", + "title_template": "Success!", "message_template": """ -

{{ new_member.user_name }} was successfully invited via email to this portfolio. They do not yet have access to any environments.

-

Add environment access.

+

You have successfully invited {{ new_member.user_name }} to the portfolio admin.

""", "category": "success", }, diff --git a/js/components/forms/multi_step_modal_form.js b/js/components/forms/multi_step_modal_form.js new file mode 100644 index 00000000..da2cc5d4 --- /dev/null +++ b/js/components/forms/multi_step_modal_form.js @@ -0,0 +1,82 @@ +import FormMixin from '../../mixins/form' +import textinput from '../text_input' +import optionsinput from '../options_input' +import Selector from '../selector' +import Modal from '../../mixins/modal' +import toggler from '../toggler' + +export default { + name: 'multi-step-modal-form', + + mixins: [FormMixin, Modal], + + components: { + toggler, + Modal, + Selector, + textinput, + optionsinput, + }, + + props: { + steps: Number, + }, + + data: function() { + return { + step: 0, + fields: {}, + invalid: true, + } + }, + + created: function() { + this.$root.$on('field-mount', this.handleFieldMount) + }, + + mounted: function() { + this.$root.$on('field-change', this.handleValidChange) + this.$on('modalOpen', this.handleModalOpen) + }, + + methods: { + next: function() { + if (this._checkIsValid()) { + this.step += 1 + } + }, + goToStep: function(step) { + if (this._checkIsValid()) { + this.step = step + } + }, + handleValidChange: function(event) { + const { name, valid } = event + this.fields[name] = valid + this._checkIsValid() + }, + _checkIsValid: function() { + const valid = !Object.values(this.fields).some(field => field === false) + this.invalid = !valid + return valid + }, + handleFieldMount: function(event) { + const { name, optional } = event + this.fields[name] = optional + }, + handleModalOpen: function(_bool) { + this.step = 0 + }, + _onLastPage: function() { + return this.step === this.steps - 1 + }, + handleSubmit: function(e) { + if (this.invalid || !this._onLastPage()) { + e.preventDefault() + this.next() + } + }, + }, + + computed: {}, +} diff --git a/js/components/text_input.js b/js/components/text_input.js index 98234f4d..cb838025 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -25,6 +25,7 @@ export default { }, paragraph: String, noMaxWidth: String, + optional: Boolean, }, data: function() { @@ -64,6 +65,13 @@ export default { } }, + created: function() { + this.$root.$emit('field-mount', { + name: this.name, + optional: this.optional, + }) + }, + methods: { // When user types a character onInput: function(e) { @@ -82,7 +90,9 @@ export default { }, onBlur: function(e) { - this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + if (!(this.optional && e.target.value === '')) { + this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + } this.value = e.target.value.trim() if (this.validation === 'dollars') { @@ -97,6 +107,8 @@ export default { if (!this.modified && this.initialErrors && this.initialErrors.length) { valid = false + } else if (this.optional && value === '') { + valid = true } if (this.modified) { diff --git a/js/index.js b/js/index.js index 654be95c..a5e58f45 100644 --- a/js/index.js +++ b/js/index.js @@ -18,6 +18,7 @@ import toggler from './components/toggler' import NewApplication from './components/forms/new_application' import EditEnvironmentRole from './components/forms/edit_environment_role' import EditApplicationRoles from './components/forms/edit_application_roles' +import MultiStepModalForm from './components/forms/multi_step_modal_form' import funding from './components/forms/funding' import uploadinput from './components/upload_input' import Modal from './mixins/modal' @@ -59,6 +60,7 @@ const app = new Vue({ LocalDatetime, EditEnvironmentRole, EditApplicationRoles, + MultiStepModalForm, ConfirmationPopover, funding, uploadinput, diff --git a/static/icons/checkmark-alt.svg b/static/icons/checkmark-alt.svg new file mode 100644 index 00000000..b674534c --- /dev/null +++ b/static/icons/checkmark-alt.svg @@ -0,0 +1 @@ + diff --git a/styles/components/_modal.scss b/styles/components/_modal.scss index 90015844..dd68fd64 100644 --- a/styles/components/_modal.scss +++ b/styles/components/_modal.scss @@ -139,4 +139,81 @@ body { } } } + + &.wide { + .modal__dialog { + max-width: 90rem; + } + + .modal__body { + padding-left: 4rem; + padding-right: 4rem; + } + } + + .modal__form { + + .modal__form--header { + margin-bottom: 4rem; + + h1 { + margin-bottom: 0; + } + + .icon-link { + padding-top: 0.5rem; + padding-left: 0; + margin-left: 0; + } + } + + .progress-menu ul { + width: 40%; + margin-left: 30%; + font-size: 2rem; + + .progress-menu__item::before { + width: 2.8rem; + height: 2.8rem; + margin-left: -1.25rem; + } + + .progress-menu__item--complete::before { + content: url('#{$asset-path}/icons/checkmark-alt.svg'); + padding-top: 0.4rem; + } + } + + .form-row { + margin-top: 0; + + .form-col { + .usa-input { + margin-bottom: 1.5rem; + } + } + } + + .icon-link--default { + font-size: 1.7rem; + } + + .usa-button { + margin-left: 2rem; + + &[type='button']:disabled { + background-color: $color-gray-lighter; + opacity: inherit; + } + } + + .modal__form--padded { + padding-left: 5%; + padding-right: 5%; + + .usa-input .usa-input__choices select { + max-width: 100%; + } + } + } } diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 2ae5f61e..539ef47b 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -197,6 +197,7 @@ } table { + thead { th:first-child { padding-left: 3 * $gap; @@ -294,6 +295,25 @@ float: right; padding: 3 * $gap; } + + a.modal-link.icon-link { + float: right; + + .icon { + width: 1.7rem; + height: 1.7rem; + + svg { + width: 1.7rem; + height: 1.7rem; + } + } + + } + + .alert { + margin: 4rem; + } } .application-content { diff --git a/templates/components/modal.html b/templates/components/modal.html index 9df49137..c7b582ff 100644 --- a/templates/components/modal.html +++ b/templates/components/modal.html @@ -1,8 +1,8 @@ {% from "components/icon.html" import Icon %} -{% macro Modal(name, dismissable=False) -%} +{% macro Modal(name, dismissable=False, classes="") -%}
-