ui: Intention "Action change" warning modal (#9108)

* ui: Add a warning dialog if you go to remove permissions from an intention

* ui: Move modal styles next to component, add warning style

* ui: Move back to using the input name for a selector

* ui: Fixup negative "isn't" step so its optional

* Add warning modal to pageobject

* Fixup test for whether to show the warning modal or not

* Intention change action warning acceptence test

* Add a null/undefined Action
This commit is contained in:
John Cowen 2020-11-06 14:57:19 +00:00 committed by GitHub
parent 137f7d0a92
commit 1b042943e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 251 additions and 83 deletions

View File

@ -1,3 +1,7 @@
<div
class="consul-intention"
...attributes
>
<DataForm <DataForm
@type="intention" @type="intention"
@dc={{@dc}} @dc={{@dc}}
@ -27,9 +31,46 @@ as |api|>
</BlockSlot> </BlockSlot>
<BlockSlot @name="form"> <BlockSlot @name="form">
{{#let api.data as |item|}} {{#let api.data as |item|}}
{{#if item.IsEditable}} {{#if item.IsEditable}}
{{#if this.warn}}
{{#let (changeset-get item 'Action') as |newAction|}}
<ModalDialog
class="consul-intention-action-warn-modal warning"
data-test-action-warning
@onclose={{action (mut this.warn) false}}
>
<BlockSlot @name="header">
<h2>Set intention to {{newAction}}?</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
When you change this Intention to {{newAction}}, you will remove all the L7 policy permissions currently saved to this Intention. Are you sure you want to set it to {{newAction}}?
</p>
</BlockSlot>
<BlockSlot @name="actions" as |close|>
<button
data-test-action-warning-confirm
type="button"
class="dangerous"
{{on 'click' api.submit}}
>
Set to {{capitalize newAction}}
</button>
<button
data-test-action-warning-cancel
type="button"
class="type-cancel"
onclick={{close}}
>
No, Cancel
</button>
</BlockSlot>
</ModalDialog>
{{/let}}
{{/if}}
<DataSource <DataSource
@src={{concat '/' @nspace '/' @dc '/services'}} @src={{concat '/' @nspace '/' @dc '/services'}}
@onchange={{action this.createServices item}} @onchange={{action this.createServices item}}
@ -43,7 +84,9 @@ as |api|>
{{#if (and api.isCreate this.isManagedByCRDs)}} {{#if (and api.isCreate this.isManagedByCRDs)}}
<Consul::Intention::Notice::CustomResource @type="warning" /> <Consul::Intention::Notice::CustomResource @type="warning" />
{{/if}} {{/if}}
<form onsubmit={{action api.submit}}> <form
{{on 'submit' (fn this.submit item api.submit)}}
>
<Consul::Intention::Form::Fieldsets <Consul::Intention::Form::Fieldsets
@nspaces={{this.nspaces}} @nspaces={{this.nspaces}}
@services={{this.services}} @services={{this.services}}
@ -127,3 +170,4 @@ as |api|>
</BlockSlot> </BlockSlot>
</DataForm> </DataForm>
</div>

View File

@ -4,7 +4,6 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
export default class ConsulIntentionForm extends Component { export default class ConsulIntentionForm extends Component {
@tracked services; @tracked services;
@tracked SourceName; @tracked SourceName;
@tracked DestinationName; @tracked DestinationName;
@ -15,6 +14,8 @@ export default class ConsulIntentionForm extends Component {
@tracked isManagedByCRDs; @tracked isManagedByCRDs;
@tracked warn = false;
@service('repository/intention') repo; @service('repository/intention') repo;
constructor(owner, args) { constructor(owner, args) {
@ -23,7 +24,7 @@ export default class ConsulIntentionForm extends Component {
} }
ondelete() { ondelete() {
if(this.args.ondelete) { if (this.args.ondelete) {
this.args.ondelete(...arguments); this.args.ondelete(...arguments);
} else { } else {
this.onsubmit(...arguments); this.onsubmit(...arguments);
@ -31,7 +32,7 @@ export default class ConsulIntentionForm extends Component {
} }
oncancel() { oncancel() {
if(this.args.oncancel) { if (this.args.oncancel) {
this.args.oncancel(...arguments); this.args.oncancel(...arguments);
} else { } else {
this.onsubmit(...arguments); this.onsubmit(...arguments);
@ -39,7 +40,7 @@ export default class ConsulIntentionForm extends Component {
} }
onsubmit() { onsubmit() {
if(this.args.onsubmit) { if (this.args.onsubmit) {
this.args.onsubmit(...arguments); this.args.onsubmit(...arguments);
} }
} }
@ -48,9 +49,19 @@ export default class ConsulIntentionForm extends Component {
updateCRDManagement() { updateCRDManagement() {
this.isManagedByCRDs = this.repo.isManagedByCRDs(); this.isManagedByCRDs = this.repo.isManagedByCRDs();
} }
@action @action
createServices (item, e) { submit(item, submit, e) {
e.preventDefault();
// if the action of the intention has changed and its non-empty then warn
// the user
if (typeof item.change.Action !== 'undefined' && typeof item.data.Action === 'undefined') {
this.warn = true;
} else {
submit();
}
}
@action
createServices(item, e) {
// Services in the menus should: // Services in the menus should:
// 1. Be unique (they potentially could be duplicated due to services from different namespaces) // 1. Be unique (they potentially could be duplicated due to services from different namespaces)
// 2. Only include services that shold have intentions // 2. Only include services that shold have intentions
@ -59,9 +70,7 @@ export default class ConsulIntentionForm extends Component {
let items = e.data let items = e.data
.uniqBy('Name') .uniqBy('Name')
.toArray() .toArray()
.filter( .filter(item => !['connect-proxy', 'mesh-gateway', 'terminating-gateway'].includes(item.Kind))
item => !['connect-proxy', 'mesh-gateway', 'terminating-gateway'].includes(item.Kind)
)
.sort((a, b) => a.Name.localeCompare(b.Name)); .sort((a, b) => a.Name.localeCompare(b.Name));
items = [{ Name: '*' }].concat(items); items = [{ Name: '*' }].concat(items);
let source = items.findBy('Name', item.SourceName); let source = items.findBy('Name', item.SourceName);
@ -80,7 +89,7 @@ export default class ConsulIntentionForm extends Component {
} }
@action @action
createNspaces (item, e) { createNspaces(item, e) {
// Nspaces in the menus should: // Nspaces in the menus should:
// 1. Include an 'All Namespaces' option // 1. Include an 'All Namespaces' option
// 2. Include the current SourceNS and DestinationNS incase they don't exist yet // 2. Include the current SourceNS and DestinationNS incase they don't exist yet

View File

@ -0,0 +1,11 @@
.consul-intention-action-warn-modal {
.modal-dialog-window {
max-width: 450px;
}
.modal-dialog-body p {
font-size: $typo-size-600;
}
button.dangerous {
@extend %dangerous-button;
}
}

View File

@ -2,6 +2,7 @@
@import './search-bar'; @import './search-bar';
@import './list'; @import './list';
@import './form';
@import './form/fieldsets'; @import './form/fieldsets';
@import './permission/list'; @import './permission/list';
@import './permission/form'; @import './permission/form';

View File

@ -1,28 +1,44 @@
{{on-window 'resize' (action "resize") }} {{on-window 'resize' (action "resize") }}
<Portal @target="modal"> <Portal @target="modal">
{{yield}} {{yield}}
<div {{ref this 'modal'}} ...attributes> <div
<input id={{name}} type="radio" name="modal" data-checked="{{checked}}" checked={{checked}} onchange={{action 'change'}} /> class="modal-dialog"
<div role="dialog" aria-modal="true"> {{ref this 'modal'}}
...attributes
>
<input
class="modal-dialog-control"
id={{name}}
type="radio"
name="modal"
data-checked="{{checked}}"
checked={{checked}}
onchange={{action 'change'}}
/>
<div
class="modal-dialog-modal"
role="dialog"
aria-modal="true"
>
<label for="modal_close"></label> <label for="modal_close"></label>
<div> <div>
<div> <div class="modal-dialog-window">
<header> <header class="modal-dialog-header">
<label for="modal_close">Close</label> <label for="modal_close"></label>
<YieldSlot @name="header"> <YieldSlot @name="header">
{{yield (hash {{yield (hash
close=(action "close") close=(action "close")
)}} )}}
</YieldSlot> </YieldSlot>
</header> </header>
<div> <div class="modal-dialog-body">
<YieldSlot @name="body"> <YieldSlot @name="body">
{{yield (hash {{yield (hash
close=(action "close") close=(action "close")
)}} )}}
</YieldSlot> </YieldSlot>
</div> </div>
<footer> <footer class="modal-dialog-footer">
<YieldSlot @name="actions" @params={{block-params (action "close")}}> <YieldSlot @name="actions" @params={{block-params (action "close")}}>
{{yield (hash {{yield (hash
close=(action "close") close=(action "close")

View File

@ -19,7 +19,7 @@ export default Component.extend(Slotted, {
set(this, 'checked', true); set(this, 'checked', true);
if (this.height === null) { if (this.height === null) {
if (this.element) { if (this.element) {
const dialogPanel = this.dom.element('[role="dialog"] > div > div', this.modal); const dialogPanel = this.dom.element('.modal-dialog-window', this.modal);
const rect = dialogPanel.getBoundingClientRect(); const rect = dialogPanel.getBoundingClientRect();
set(this, 'dialog', dialogPanel); set(this, 'dialog', dialogPanel);
set(this, 'height', rect.height); set(this, 'height', rect.height);

View File

@ -0,0 +1,14 @@
@import './skin';
@import './layout';
.modal-dialog-modal {
@extend %modal-dialog;
}
input[name='modal'] {
@extend %modal-control;
}
html.template-with-modal {
@extend %with-modal;
}
%modal-dialog table {
min-height: 149px;
}

View File

@ -70,6 +70,7 @@
%modal-window > header [for='modal_close'] { %modal-window > header [for='modal_close'] {
float: right; float: right;
text-indent: -9000px; text-indent: -9000px;
width: 23px; width: 24px;
height: 23px; height: 24px;
margin-top: -3px;
} }

View File

@ -0,0 +1,55 @@
.modal-dialog.warning header {
background-color: $yellow-050;
border-color: $yellow-500;
color: $yellow-800;
}
.modal-dialog.warning header > *:not(label) {
font-size: $typo-size-500;
font-weight: $typo-weight-semibold;
}
.modal-dialog.warning header::before {
@extend %with-alert-triangle-mask, %as-pseudo;
color: $yellow-500;
float: left;
margin-top: 2px;
margin-right: 3px;
}
%modal-dialog > label {
background-color: rgba($white, 0.9);
}
%modal-window {
box-shadow: $decor-elevation-800;
}
%modal-window {
/*%frame-gray-000*/
background-color: $white;
}
%modal-window > footer,
%modal-window > header {
@extend %frame-gray-800;
}
.modal-dialog-body,
%modal-window > footer,
%modal-window > header {
border-color: $gray-300;
}
.modal-dialog-body {
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
}
%modal-window > footer,
%modal-window > header {
border-width: 1px;
}
%modal-window > header [for='modal_close'] {
@extend %with-cancel-plain-icon;
cursor: pointer;
border: $decor-border-100;
/*%frame-gray-050??*/
background-color: $gray-050;
border-color: $gray-300;
border-radius: $decor-radius-100;
}

View File

@ -2,6 +2,7 @@ import Serializer from './application';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/intention'; import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/intention';
import removeNull from 'consul-ui/utils/remove-null';
export default Serializer.extend({ export default Serializer.extend({
primaryKey: PRIMARY_KEY, primaryKey: PRIMARY_KEY,
@ -28,7 +29,7 @@ export default Serializer.extend({
respond((headers, body) => { respond((headers, body) => {
return cb( return cb(
headers, headers,
body.map(item => this.ensureID(item)) body.map(item => this.ensureID(removeNull(item)))
); );
}), }),
query query
@ -38,7 +39,7 @@ export default Serializer.extend({
return this._super( return this._super(
cb => cb =>
respond((headers, body) => { respond((headers, body) => {
body = this.ensureID(body); body = this.ensureID(removeNull(body));
return cb(headers, body); return cb(headers, body);
}), }),
query query

View File

@ -7,7 +7,6 @@
@import './form-elements/index'; @import './form-elements/index';
@import './inline-alert/index'; @import './inline-alert/index';
@import './menu-panel/index'; @import './menu-panel/index';
@import './modal-dialog/index';
@import './pill/index'; @import './pill/index';
@import './popover-menu/index'; @import './popover-menu/index';
@import './radio-group/index'; @import './radio-group/index';

View File

@ -1,2 +0,0 @@
@import './skin';
@import './layout';

View File

@ -1,32 +0,0 @@
%modal-dialog > label {
background-color: rgba($white, 0.9);
}
%modal-window {
box-shadow: $decor-elevation-800;
}
%modal-window {
/*%frame-gray-000*/
background-color: $white;
border: $decor-border-100;
border-color: $gray-300;
}
%modal-window > footer,
%modal-window > header {
@extend %frame-gray-800;
}
%modal-window > footer {
border-top-width: 1px;
}
%modal-window > header {
border-bottom-width: 1px;
}
%modal-window > header [for='modal_close'] {
@extend %with-cancel-plain-icon;
cursor: pointer;
border: $decor-border-100;
/*%frame-gray-050??*/
background-color: $gray-050;
border-color: $gray-300;
border-radius: $decor-radius-100;
}

View File

@ -26,7 +26,6 @@
@import './components/flash-message'; @import './components/flash-message';
@import './components/code-editor'; @import './components/code-editor';
@import './components/confirmation-dialog'; @import './components/confirmation-dialog';
@import './components/modal-dialog';
@import './components/auth-form'; @import './components/auth-form';
@import './components/auth-modal'; @import './components/auth-modal';
@import './components/oidc-select'; @import './components/oidc-select';
@ -55,6 +54,7 @@
/**/ /**/
@import 'consul-ui/components/notice'; @import 'consul-ui/components/notice';
@import 'consul-ui/components/modal-dialog';
@import 'consul-ui/components/consul/exposed-path/list'; @import 'consul-ui/components/consul/exposed-path/list';
@import 'consul-ui/components/consul/external-source'; @import 'consul-ui/components/consul/external-source';

View File

@ -1,8 +1,9 @@
%auth-modal footer { %auth-modal footer {
border: 0; border-top: 0;
padding-top: 10px; padding-top: 10px;
padding-bottom: 20px; padding-bottom: 20px;
margin: 0px 26px; padding-left: 42px;
padding-right: 42px;
} }
%auth-modal footer { %auth-modal footer {
background-color: $transparent; background-color: $transparent;

View File

@ -4,6 +4,12 @@ label span {
.has-error { .has-error {
@extend %form-element-error; @extend %form-element-error;
} }
// TODO: float right here is too specific, this is currently used just for the role/policy selectors
label.type-dialog {
@extend %anchor;
cursor: pointer;
float: right;
}
.type-toggle { .type-toggle {
@extend %form-element, %sliding-toggle; @extend %form-element, %sliding-toggle;
} }

View File

@ -1,18 +0,0 @@
[role='dialog'] {
@extend %modal-dialog;
}
input[name='modal'] {
@extend %modal-control;
}
html.template-with-modal {
@extend %with-modal;
}
%modal-dialog table {
min-height: 149px;
}
// TODO: float right here is too specific, this is currently used just for the role/policy selectors
label.type-dialog {
@extend %anchor;
cursor: pointer;
float: right;
}

View File

@ -0,0 +1,31 @@
@setupApplicationTest
Feature: dc / intentions / permissions / warn: Intention Permission Warn
Scenario:
Given 1 datacenter model with the value "datacenter"
And 1 intention model from yaml
---
SourceNS: default
SourceName: web
DestinationNS: default
DestinationName: db
Action: ~
Permissions:
- Action: allow
HTTP:
PathExact: /path
---
When I visit the intention page for yaml
---
dc: datacenter
intention: intention-id
---
Then the url should be /datacenter/intentions/intention-id
And I click "[value='deny']"
And I submit
And the warning object is present
And I click the warning.cancel object
And the warning object isn't present
And I submit
And the warning object is present
And I click the warning.confirm object
Then a PUT request was made to "/v1/connect/intentions/exact?source=default%2Fweb&destination=default%2Fdb&dc=datacenter" from yaml

View File

@ -0,0 +1,10 @@
import steps from '../../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -94,7 +94,13 @@ const morePopoverMenu = morePopoverMenuFactory(clickable);
const popoverSelect = popoverSelectFactory(clickable, collection); const popoverSelect = popoverSelectFactory(clickable, collection);
const emptyState = emptyStateFactory(isPresent); const emptyState = emptyStateFactory(isPresent);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, isPresent, deletable); const consulIntentionList = consulIntentionListFactory(
collection,
clickable,
attribute,
isPresent,
deletable
);
const consulNspaceList = consulNspaceListFactory( const consulNspaceList = consulNspaceListFactory(
collection, collection,
clickable, clickable,
@ -176,6 +182,7 @@ export default {
intention( intention(
visitable, visitable,
clickable, clickable,
isPresent,
submitable, submitable,
deletable, deletable,
cancelable, cancelable,

View File

@ -1,6 +1,7 @@
export default function( export default function(
visitable, visitable,
clickable, clickable,
isPresent,
submitable, submitable,
deletable, deletable,
cancelable, cancelable,
@ -18,6 +19,19 @@ export default function(
form: permissionsForm(), form: permissionsForm(),
list: permissionsList(), list: permissionsList(),
}, },
warning: {
scope: '[data-test-action-warning]',
resetScope: true,
present: isPresent(),
confirm: {
scope: '[data-test-action-warning-confirm]',
click: clickable(),
},
cancel: {
scope: '[data-test-action-warning-cancel]',
click: clickable(),
},
},
...submitable(), ...submitable(),
...cancelable(), ...cancelable(),
...deletable(), ...deletable(),

View File

@ -47,7 +47,7 @@ export default function(scenario, assert, find, currentPage, $) {
setTimeout(() => next()); setTimeout(() => next());
} }
) )
.then(`the $pageObject object is(n't) $state`, function(element, negative, state, next) { .then(`the $pageObject object is(n't)? $state`, function(element, negative, state, next) {
assert[negative ? 'notOk' : 'ok'](element[state]); assert[negative ? 'notOk' : 'ok'](element[state]);
setTimeout(() => next()); setTimeout(() => next());
}) })